1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-25 00:53:22 +00:00

Merge branch 'main' into km/pm-18576/fix-missing-userid-on-remove-password

This commit is contained in:
Maciej Zieniuk
2025-03-13 12:25:25 +00:00
221 changed files with 3745 additions and 5134 deletions

View File

@@ -39,11 +39,10 @@ export class Collection extends Domain {
}
decrypt(orgKey: OrgKey): Promise<CollectionView> {
return this.decryptObj(
return this.decryptObj<Collection, CollectionView>(
this,
new CollectionView(this),
{
name: null,
},
["name"],
this.organizationId,
orgKey,
);

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { animate, state, style, transition, trigger } from "@angular/animations";
import { ConnectedPosition } from "@angular/cdk/overlay";
import { Component, EventEmitter, Output, Input, OnInit, OnDestroy } from "@angular/core";
@@ -7,8 +5,6 @@ import { ActivatedRoute } from "@angular/router";
import { Observable, map, Subject, takeUntil } from "rxjs";
import { SelfHostedEnvConfigDialogComponent } from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Region,
@@ -88,7 +84,6 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
protected environmentService: EnvironmentService,
private route: ActivatedRoute,
private dialogService: DialogService,
private configService: ConfigService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
@@ -113,24 +108,18 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
}
/**
* Opens the self-hosted settings dialog.
*
* If the `UnauthenticatedExtensionUIRefresh` feature flag is enabled,
* the self-hosted settings dialog is opened directly. Otherwise, the
* `onOpenSelfHostedSettings` event is emitted.
* Opens the self-hosted settings dialog when the self-hosted option is selected.
*/
if (option === Region.SelfHosted) {
if (await this.configService.getFeatureFlag(FeatureFlag.UnauthenticatedExtensionUIRefresh)) {
if (await SelfHostedEnvConfigDialogComponent.open(this.dialogService)) {
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("environmentSaved"),
});
}
} else {
this.onOpenSelfHostedSettings.emit();
}
if (
option === Region.SelfHosted &&
(await SelfHostedEnvConfigDialogComponent.open(this.dialogService))
) {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("environmentSaved"),
});
return;
}

View File

@@ -1,401 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, Validators } from "@angular/forms";
import { ActivatedRoute, NavigationSkipped, Router } from "@angular/router";
import { Subject, firstValueFrom, of } from "rxjs";
import { switchMap, take, takeUntil } from "rxjs/operators";
import {
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
PasswordLoginCredentials,
} from "@bitwarden/auth/common";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
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 { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import {
AllValidationErrors,
FormValidationErrorsService,
} from "../../platform/abstractions/form-validation-errors.service";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
@Directive()
export class LoginComponentV1 extends CaptchaProtectedComponent implements OnInit, OnDestroy {
@ViewChild("masterPasswordInput", { static: true }) masterPasswordInput: ElementRef;
showPassword = false;
formPromise: Promise<AuthResult>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: (userId: UserId) => Promise<any>;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
showLoginWithDevice: boolean;
validatedEmail = false;
paramEmailSet = false;
get emailFormControl() {
return this.formGroup.controls.email;
}
formGroup = this.formBuilder.nonNullable.group({
email: ["", [Validators.required, Validators.email]],
masterPassword: [
"",
[Validators.required, Validators.minLength(Utils.originalMinimumPasswordLength)],
],
rememberEmail: [false],
});
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
protected destroy$ = new Subject<void>();
get loggedEmail() {
return this.formGroup.controls.email.value;
}
constructor(
protected devicesApiService: DevicesApiServiceAbstraction,
protected appIdService: AppIdService,
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected router: Router,
platformUtilsService: PlatformUtilsService,
i18nService: I18nService,
protected stateService: StateService,
environmentService: EnvironmentService,
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
protected cryptoFunctionService: CryptoFunctionService,
protected logService: LogService,
protected ngZone: NgZone,
protected formBuilder: FormBuilder,
protected formValidationErrorService: FormValidationErrorsService,
protected route: ActivatedRoute,
protected loginEmailService: LoginEmailServiceAbstraction,
protected ssoLoginService: SsoLoginServiceAbstraction,
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
}
async ngOnInit() {
this.route?.queryParams
.pipe(
switchMap((params) => {
if (!params) {
// If no params,loadEmailSettings from state
return this.loadEmailSettings();
}
const queryParamsEmail = params.email;
if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) {
this.formGroup.controls.email.setValue(queryParamsEmail);
this.paramEmailSet = true;
}
// If paramEmailSet is false, loadEmailSettings from state
return this.paramEmailSet ? of(null) : this.loadEmailSettings();
}),
takeUntil(this.destroy$),
)
.subscribe();
// If the user navigates to /login from /login, reset the validatedEmail flag
// This should bring the user back to the login screen with the email field
this.router.events.pipe(takeUntil(this.destroy$)).subscribe((event) => {
if (event instanceof NavigationSkipped && event.url === "/login") {
this.validatedEmail = false;
}
});
// Backup check to handle unknown case where activatedRoute is not available
// This shouldn't happen under normal circumstances
if (!this.route) {
await this.loadEmailSettings();
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
async submit(showToast = true) {
await this.setupCaptcha();
this.formGroup.markAllAsTouched();
//web
if (this.formGroup.invalid && !showToast) {
return;
}
//desktop, browser; This should be removed once all clients use reactive forms
if (this.formGroup.invalid && showToast) {
const errorText = this.getErrorToastMessage();
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccurred"),
message: errorText,
});
return;
}
try {
const credentials = new PasswordLoginCredentials(
this.formGroup.controls.email.value,
this.formGroup.controls.masterPassword.value,
this.captchaToken,
undefined,
);
this.formPromise = this.loginStrategyService.logIn(credentials);
const response = await this.formPromise;
await this.saveEmailSettings();
if (this.handleCaptchaRequired(response)) {
return;
} else if (await this.handleMigrateEncryptionKey(response)) {
return;
} else if (response.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginTwoFactorNavigate();
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.twoFactorRoute]);
}
} else if (response.forcePasswordReset != ForceSetPasswordReason.None) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginForceResetNavigate();
} else {
this.loginEmailService.clearValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
if (this.onSuccessfulLogin != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginNavigate(response.userId);
} else {
this.loginEmailService.clearValues();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.successRoute]);
}
}
} catch (e) {
this.logService.error(e);
}
}
togglePassword() {
this.showPassword = !this.showPassword;
if (this.ngZone.isStable) {
document.getElementById("masterPassword").focus();
} else {
this.ngZone.onStable
.pipe(take(1))
.subscribe(() => document.getElementById("masterPassword").focus());
}
}
async startAuthRequestLogin() {
this.formGroup.get("masterPassword")?.clearValidators();
this.formGroup.get("masterPassword")?.updateValueAndValidity();
if (!this.formGroup.valid) {
return;
}
await this.saveEmailSettings();
await this.router.navigate(["/login-with-device"]);
}
async launchSsoBrowser(clientId: string, ssoRedirectUri: string) {
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
// Generate necessary sso params
const passwordOptions: any = {
type: "password",
length: 64,
uppercase: true,
lowercase: true,
numbers: true,
special: false,
};
const state = await this.passwordGenerationService.generatePassword(passwordOptions);
const ssoCodeVerifier = await this.passwordGenerationService.generatePassword(passwordOptions);
const codeVerifierHash = await this.cryptoFunctionService.hash(ssoCodeVerifier, "sha256");
const codeChallenge = Utils.fromBufferToUrlB64(codeVerifierHash);
// Save sso params
await this.ssoLoginService.setSsoState(state);
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
// Build URI
const env = await firstValueFrom(this.environmentService.environment$);
const webUrl = env.getWebVaultUrl();
// Launch browser
this.platformUtilsService.launchUri(
webUrl +
"/#/sso?clientId=" +
clientId +
"&redirectUri=" +
encodeURIComponent(ssoRedirectUri) +
"&state=" +
state +
"&codeChallenge=" +
codeChallenge +
"&email=" +
encodeURIComponent(this.formGroup.controls.email.value),
);
}
async validateEmail() {
this.formGroup.controls.email.markAsTouched();
const emailValid = this.formGroup.get("email").valid;
if (emailValid) {
this.toggleValidateEmail(true);
await this.getLoginWithDevice(this.loggedEmail);
}
}
toggleValidateEmail(value: boolean) {
this.validatedEmail = value;
if (!this.validatedEmail) {
// Reset master password only when going from validated to not validated
// so that autofill can work properly
this.formGroup.controls.masterPassword.reset();
} else {
// Mark MP as untouched so that, when users enter email and hit enter,
// the MP field doesn't load with validation errors
this.formGroup.controls.masterPassword.markAsUntouched();
// When email is validated, focus on master password after
// waiting for input to be rendered
if (this.ngZone.isStable) {
this.masterPasswordInput?.nativeElement?.focus();
} else {
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
this.masterPasswordInput?.nativeElement?.focus();
});
}
}
}
private async loadEmailSettings() {
// Try to load from memory first
const email = await firstValueFrom(this.loginEmailService.loginEmail$);
const rememberEmail = this.loginEmailService.getRememberEmail();
if (email) {
this.formGroup.controls.email.setValue(email);
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
} else {
// If not in memory, check email on disk
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
if (storedEmail) {
// If we have a stored email, rememberEmail should default to true
this.formGroup.controls.email.setValue(storedEmail);
this.formGroup.controls.rememberEmail.setValue(true);
}
}
}
protected async saveEmailSettings() {
// Save off email for SSO
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
this.loginEmailService.setLoginEmail(this.formGroup.value.email);
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
await this.loginEmailService.saveEmailSettings();
}
// Legacy accounts used the master key to encrypt data. Migration is required but only performed on web
protected async handleMigrateEncryptionKey(result: AuthResult): Promise<boolean> {
if (!result.requiresEncryptionKeyMigration) {
return false;
}
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("errorOccured"),
message: this.i18nService.t("encryptionKeyMigrationRequired"),
});
return true;
}
private getErrorToastMessage() {
const error: AllValidationErrors = this.formValidationErrorService
.getFormValidationErrors(this.formGroup.controls)
.shift();
if (error) {
switch (error.errorName) {
case "email":
return this.i18nService.t("invalidEmail");
case "minlength":
return this.i18nService.t("masterPasswordMinlength", Utils.originalMinimumPasswordLength);
default:
return this.i18nService.t(this.errorTag(error));
}
}
return;
}
private errorTag(error: AllValidationErrors): string {
const name = error.errorName.charAt(0).toUpperCase() + error.errorName.slice(1);
return `${error.controlName}${name}`;
}
async getLoginWithDevice(email: string) {
try {
const deviceIdentifier = await this.appIdService.getAppId();
this.showLoginWithDevice = await this.devicesApiService.getKnownDevice(
email,
deviceIdentifier,
);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
this.showLoginWithDevice = false;
}
}
}

View File

@@ -1,538 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, OnDestroy, OnInit } from "@angular/core";
import { IsActiveMatchOptions, Router } from "@angular/router";
import { Subject, firstValueFrom, map, takeUntil } from "rxjs";
import {
AuthRequestLoginCredentials,
AuthRequestServiceAbstraction,
LoginStrategyServiceAbstraction,
LoginEmailServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { HttpStatusCode } from "@bitwarden/common/enums/http-status-code.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "@bitwarden/common/types/guid";
import { ToastService } from "@bitwarden/components";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { KeyService } from "@bitwarden/key-management";
import { CaptchaProtectedComponent } from "./captcha-protected.component";
enum State {
StandardAuthRequest,
AdminAuthRequest,
}
@Directive()
export class LoginViaAuthRequestComponentV1
extends CaptchaProtectedComponent
implements OnInit, OnDestroy
{
private destroy$ = new Subject<void>();
userAuthNStatus: AuthenticationStatus;
email: string;
showResendNotification = false;
authRequest: AuthRequest;
fingerprintPhrase: string;
onSuccessfulLoginTwoFactorNavigate: () => Promise<any>;
onSuccessfulLogin: () => Promise<any>;
onSuccessfulLoginNavigate: () => Promise<any>;
onSuccessfulLoginForceResetNavigate: () => Promise<any>;
protected adminApprovalRoute = "admin-approval-requested";
protected StateEnum = State;
protected state = State.StandardAuthRequest;
protected webVaultUrl: string;
protected twoFactorRoute = "2fa";
protected successRoute = "vault";
protected forcePasswordResetRoute = "update-temp-password";
private resendTimeout = 12000;
protected deviceManagementUrl: string;
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
constructor(
protected router: Router,
private keyService: KeyService,
private cryptoFunctionService: CryptoFunctionService,
private appIdService: AppIdService,
private passwordGenerationService: PasswordGenerationServiceAbstraction,
private apiService: ApiService,
private authService: AuthService,
private logService: LogService,
environmentService: EnvironmentService,
i18nService: I18nService,
platformUtilsService: PlatformUtilsService,
private anonymousHubService: AnonymousHubService,
private validationService: ValidationService,
private accountService: AccountService,
private loginEmailService: LoginEmailServiceAbstraction,
private deviceTrustService: DeviceTrustServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private loginStrategyService: LoginStrategyServiceAbstraction,
protected toastService: ToastService,
) {
super(environmentService, i18nService, platformUtilsService, toastService);
// Get the web vault URL from the environment service
environmentService.environment$.pipe(takeUntil(this.destroy$)).subscribe((env) => {
this.webVaultUrl = env.getWebVaultUrl();
this.deviceManagementUrl = `${this.webVaultUrl}/#/settings/security/device-management`;
});
// Gets signalR push notification
// Only fires on approval to prevent enumeration
this.authRequestService.authRequestPushNotification$
.pipe(takeUntil(this.destroy$))
.subscribe((id) => {
this.verifyAndHandleApprovedAuthReq(id).catch((e: Error) => {
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
});
this.logService.error("Failed to use approved auth request: " + e.message);
});
});
}
async ngOnInit() {
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
this.userAuthNStatus = await this.authService.getAuthStatus();
const matchOptions: IsActiveMatchOptions = {
paths: "exact",
queryParams: "ignored",
fragment: "ignored",
matrixParams: "ignored",
};
if (this.router.isActive(this.adminApprovalRoute, matchOptions)) {
this.state = State.AdminAuthRequest;
}
if (this.state === State.AdminAuthRequest) {
// Pull email from state for admin auth reqs b/c it is available
// This also prevents it from being lost on refresh as the
// login service email does not persist.
this.email = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.email)),
);
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
if (!this.email) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("userEmailMissing"),
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/login-initiated"]);
return;
}
// We only allow a single admin approval request to be active at a time
// so must check state to see if we have an existing one or not
const adminAuthReqStorable = await this.authRequestService.getAdminAuthRequest(userId);
if (adminAuthReqStorable) {
await this.handleExistingAdminAuthRequest(adminAuthReqStorable, userId);
} else {
// No existing admin auth request; so we need to create one
await this.startAuthRequestLogin();
}
} else {
// Standard auth request
// TODO: evaluate if we can remove the setting of this.email in the constructor
this.email = await firstValueFrom(this.loginEmailService.loginEmail$);
if (!this.email) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("userEmailMissing"),
});
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["/login"]);
return;
}
await this.startAuthRequestLogin();
}
}
async ngOnDestroy() {
await this.anonymousHubService.stopHubConnection();
this.destroy$.next();
this.destroy$.complete();
}
private async handleExistingAdminAuthRequest(
adminAuthReqStorable: AdminAuthRequestStorable,
userId: UserId,
) {
// Note: on login, the SSOLoginStrategy will also call to see an existing admin auth req
// has been approved and handle it if so.
// Regardless, we always retrieve the auth request from the server verify and handle status changes here as well
let adminAuthReqResponse: AuthRequestResponse;
try {
adminAuthReqResponse = await this.apiService.getAuthRequest(adminAuthReqStorable.id);
} catch (error) {
if (error instanceof ErrorResponse && error.statusCode === HttpStatusCode.NotFound) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
}
// Request doesn't exist anymore
if (!adminAuthReqResponse) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Re-derive the user's fingerprint phrase
// It is important to not use the server's public key here as it could have been compromised via MITM
const derivedPublicKeyArrayBuffer = await this.cryptoFunctionService.rsaExtractPublicKey(
adminAuthReqStorable.privateKey,
);
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
derivedPublicKeyArrayBuffer,
);
// Request denied
if (adminAuthReqResponse.isAnswered && !adminAuthReqResponse.requestApproved) {
return await this.handleExistingAdminAuthReqDeletedOrDenied(userId);
}
// Request approved
if (adminAuthReqResponse.requestApproved) {
return await this.handleApprovedAdminAuthRequest(
adminAuthReqResponse,
adminAuthReqStorable.privateKey,
userId,
);
}
// Request still pending response from admin
// set keypair and create hub connection so that any approvals will be received via push notification
this.authRequestKeyPair = { privateKey: adminAuthReqStorable.privateKey, publicKey: null };
await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
}
private async handleExistingAdminAuthReqDeletedOrDenied(userId: UserId) {
// clear the admin auth request from state
await this.authRequestService.clearAdminAuthRequest(userId);
// start new auth request
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.startAuthRequestLogin();
}
private async buildAuthRequest(authRequestType: AuthRequestType) {
const authRequestKeyPairArray = await this.cryptoFunctionService.rsaGenerateKeyPair(2048);
this.authRequestKeyPair = {
publicKey: authRequestKeyPairArray[0],
privateKey: authRequestKeyPairArray[1],
};
const deviceIdentifier = await this.appIdService.getAppId();
const publicKey = Utils.fromBufferToB64(this.authRequestKeyPair.publicKey);
const accessCode = await this.passwordGenerationService.generatePassword({
type: "password",
length: 25,
});
this.fingerprintPhrase = await this.authRequestService.getFingerprintPhrase(
this.email,
this.authRequestKeyPair.publicKey,
);
this.authRequest = new AuthRequest(
this.email,
deviceIdentifier,
publicKey,
authRequestType,
accessCode,
);
}
async startAuthRequestLogin() {
this.showResendNotification = false;
try {
let reqResponse: AuthRequestResponse;
if (this.state === State.AdminAuthRequest) {
await this.buildAuthRequest(AuthRequestType.AdminApproval);
reqResponse = await this.apiService.postAdminAuthRequest(this.authRequest);
const adminAuthReqStorable = new AdminAuthRequestStorable({
id: reqResponse.id,
privateKey: this.authRequestKeyPair.privateKey,
});
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
await this.authRequestService.setAdminAuthRequest(adminAuthReqStorable, userId);
} else {
await this.buildAuthRequest(AuthRequestType.AuthenticateAndUnlock);
reqResponse = await this.apiService.postAuthRequest(this.authRequest);
}
if (reqResponse.id) {
await this.anonymousHubService.createHubConnection(reqResponse.id);
}
} catch (e) {
this.logService.error(e);
}
setTimeout(() => {
this.showResendNotification = true;
}, this.resendTimeout);
}
private async verifyAndHandleApprovedAuthReq(requestId: string) {
try {
// Retrieve the auth request from server and verify it's approved
let authReqResponse: AuthRequestResponse;
switch (this.state) {
case State.StandardAuthRequest:
// Unauthed - access code required for user verification
authReqResponse = await this.apiService.getAuthResponse(
requestId,
this.authRequest.accessCode,
);
break;
case State.AdminAuthRequest:
// Authed - no access code required
authReqResponse = await this.apiService.getAuthRequest(requestId);
break;
default:
break;
}
if (!authReqResponse.requestApproved) {
return;
}
// Approved so proceed:
// 4 Scenarios to handle for approved auth requests:
// Existing flow 1:
// - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(masterKey)
// > decrypt masterKey > must authenticate > gets masterKey(userKey) > decrypt userKey and proceed to vault
// 3 new flows from TDE:
// Flow 2:
// - Post SSO > User is AuthN > SSO login strategy success sets masterKey(userKey) > receives approval from device with pubKey(masterKey)
// > decrypt masterKey > decrypt userKey > establish trust if required > proceed to vault
// Flow 3:
// - Post SSO > User is AuthN > Receives approval from device with pubKey(userKey) > decrypt userKey > establish trust if required > proceed to vault
// Flow 4:
// - Anon Login with Device > User is not AuthN > receives approval from device with pubKey(userKey)
// > decrypt userKey > must authenticate > set userKey > proceed to vault
// if user has authenticated via SSO
if (this.userAuthNStatus === AuthenticationStatus.Locked) {
const userId = (await firstValueFrom(this.accountService.activeAccount$)).id;
return await this.handleApprovedAdminAuthRequest(
authReqResponse,
this.authRequestKeyPair.privateKey,
userId,
);
}
// Flow 1 and 4:
const loginAuthResult = await this.loginViaAuthRequestStrategy(requestId, authReqResponse);
await this.handlePostLoginNavigation(loginAuthResult);
} catch (error) {
if (error instanceof ErrorResponse) {
let errorRoute = "/login";
if (this.state === State.AdminAuthRequest) {
errorRoute = "/login-initiated";
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([errorRoute]);
this.validationService.showError(error);
return;
}
this.logService.error(error);
}
}
async handleApprovedAdminAuthRequest(
adminAuthReqResponse: AuthRequestResponse,
privateKey: ArrayBuffer,
userId: UserId,
) {
// See verifyAndHandleApprovedAuthReq(...) for flow details
// it's flow 2 or 3 based on presence of masterPasswordHash
if (adminAuthReqResponse.masterPasswordHash) {
// Flow 2: masterPasswordHash is not null
// key is authRequestPublicKey(masterKey) + we have authRequestPublicKey(masterPasswordHash)
await this.authRequestService.setKeysAfterDecryptingSharedMasterKeyAndHash(
adminAuthReqResponse,
privateKey,
userId,
);
} else {
// Flow 3: masterPasswordHash is null
// we can assume key is authRequestPublicKey(userKey) and we can just decrypt with userKey and proceed to vault
await this.authRequestService.setUserKeyAfterDecryptingSharedUserKey(
adminAuthReqResponse,
privateKey,
userId,
);
}
// clear the admin auth request from state so it cannot be used again (it's a one time use)
// TODO: this should eventually be enforced via deleting this on the server once it is used
await this.authRequestService.clearAdminAuthRequest(userId);
this.toastService.showToast({
variant: "success",
title: null,
message: this.i18nService.t("loginApproved"),
});
// Now that we have a decrypted user key in memory, we can check if we
// need to establish trust on the current device
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
await this.deviceTrustService.trustDeviceIfRequired(activeAccount.id);
// TODO: don't forget to use auto enrollment service everywhere we trust device
await this.handleSuccessfulLoginNavigation();
}
// Authentication helper
private async buildAuthRequestLoginCredentials(
requestId: string,
response: AuthRequestResponse,
): Promise<AuthRequestLoginCredentials> {
// if masterPasswordHash has a value, we will always receive key as authRequestPublicKey(masterKey) + authRequestPublicKey(masterPasswordHash)
// if masterPasswordHash is null, we will always receive key as authRequestPublicKey(userKey)
if (response.masterPasswordHash) {
const { masterKey, masterKeyHash } =
await this.authRequestService.decryptPubKeyEncryptedMasterKeyAndHash(
response.key,
response.masterPasswordHash,
this.authRequestKeyPair.privateKey,
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
requestId,
null, // no userKey
masterKey,
masterKeyHash,
);
} else {
const userKey = await this.authRequestService.decryptPubKeyEncryptedUserKey(
response.key,
this.authRequestKeyPair.privateKey,
);
return new AuthRequestLoginCredentials(
this.email,
this.authRequest.accessCode,
requestId,
userKey,
null, // no masterKey
null, // no masterKeyHash
);
}
}
private async loginViaAuthRequestStrategy(
requestId: string,
authReqResponse: AuthRequestResponse,
): Promise<AuthResult> {
// Note: credentials change based on if the authReqResponse.key is a encryptedMasterKey or UserKey
const credentials = await this.buildAuthRequestLoginCredentials(requestId, authReqResponse);
// Note: keys are set by AuthRequestLoginStrategy success handling
return await this.loginStrategyService.logIn(credentials);
}
// Routing logic
private async handlePostLoginNavigation(loginResponse: AuthResult) {
if (loginResponse.requiresTwoFactor) {
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginTwoFactorNavigate();
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.twoFactorRoute]);
}
} else if (loginResponse.forcePasswordReset != ForceSetPasswordReason.None) {
if (this.onSuccessfulLoginForceResetNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginForceResetNavigate();
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.forcePasswordResetRoute]);
}
} else {
await this.handleSuccessfulLoginNavigation();
}
}
private async handleSuccessfulLoginNavigation() {
if (this.state === State.StandardAuthRequest) {
// Only need to set remembered email on standard login with auth req flow
await this.loginEmailService.saveEmailSettings();
}
if (this.onSuccessfulLogin != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLogin();
}
if (this.onSuccessfulLoginNavigate != null) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.onSuccessfulLoginNavigate();
} else {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate([this.successRoute]);
}
}
}

View File

@@ -17,6 +17,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { OrganizationAutoEnrollStatusResponse } from "@bitwarden/common/admin-console/models/response/organization-auto-enroll-status.response";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -62,6 +63,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
private policyApiService: PolicyApiServiceAbstraction,
policyService: PolicyService,
protected router: Router,
private masterPasswordApiService: MasterPasswordApiService,
private apiService: ApiService,
private syncService: SyncService,
private route: ActivatedRoute,
@@ -195,7 +197,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
);
try {
if (this.resetPasswordAutoEnroll) {
this.formPromise = this.apiService
this.formPromise = this.masterPasswordApiService
.setPassword(request)
.then(async () => {
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
@@ -222,7 +224,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent implements
);
});
} else {
this.formPromise = this.apiService.setPassword(request).then(async () => {
this.formPromise = this.masterPasswordApiService.setPassword(request).then(async () => {
await this.onSetPasswordSuccess(masterKey, userKey, newKeyPair);
});
}

View File

@@ -3,10 +3,10 @@
import { Directive } from "@angular/core";
import { Router } from "@angular/router";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
@@ -40,7 +40,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private apiService: ApiService,
private masterPasswordApiService: MasterPasswordApiService,
private userVerificationService: UserVerificationService,
private logService: LogService,
dialogService: DialogService,
@@ -117,9 +117,7 @@ export class UpdatePasswordComponent extends BaseChangePasswordComponent {
request.key = newUserKey[1].encryptedString;
// Update user's password
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.apiService.postPassword(request);
await this.masterPasswordApiService.postPassword(request);
this.toastService.showToast({
variant: "success",

View File

@@ -4,10 +4,10 @@ import { Directive, OnInit } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, map } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
@@ -52,7 +52,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
policyService: PolicyService,
keyService: KeyService,
messagingService: MessagingService,
private apiService: ApiService,
private masterPasswordApiService: MasterPasswordApiService,
private syncService: SyncService,
private logService: LogService,
private userVerificationService: UserVerificationService,
@@ -202,7 +202,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
request.newMasterPasswordHash = masterPasswordHash;
request.masterPasswordHint = this.hint;
return this.apiService.putUpdateTempPassword(request);
return this.masterPasswordApiService.putUpdateTempPassword(request);
}
private async updatePassword(newMasterPasswordHash: string, userKey: [UserKey, EncString]) {
@@ -214,7 +214,7 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
request.newMasterPasswordHash = newMasterPasswordHash;
request.key = userKey[1].encryptedString;
return this.apiService.postPassword(request);
return this.masterPasswordApiService.postPassword(request);
}
private async updateTdeOffboardingPassword(
@@ -226,6 +226,6 @@ export class UpdateTempPasswordComponent extends BaseChangePasswordComponent imp
request.newMasterPasswordHash = masterPasswordHash;
request.masterPasswordHint = this.hint;
return this.apiService.putUpdateTdeOffboardingPassword(request);
return this.masterPasswordApiService.putUpdateTdeOffboardingPassword(request);
}
}

View File

@@ -1,66 +0,0 @@
import { TestBed } from "@angular/core/testing";
import { Navigation, Router, UrlTree } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { unauthUiRefreshRedirect } from "./unauth-ui-refresh-redirect";
describe("unauthUiRefreshRedirect", () => {
let configService: MockProxy<ConfigService>;
let router: MockProxy<Router>;
beforeEach(() => {
configService = mock<ConfigService>();
router = mock<Router>();
TestBed.configureTestingModule({
providers: [
{ provide: ConfigService, useValue: configService },
{ provide: Router, useValue: router },
],
});
});
it("returns true when UnauthenticatedExtensionUIRefresh flag is disabled", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
const result = await TestBed.runInInjectionContext(() =>
unauthUiRefreshRedirect("/redirect")(),
);
expect(result).toBe(true);
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.UnauthenticatedExtensionUIRefresh,
);
expect(router.parseUrl).not.toHaveBeenCalled();
});
it("returns UrlTree when UnauthenticatedExtensionUIRefresh flag is enabled and preserves query params", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
const urlTree = new UrlTree();
urlTree.queryParams = { test: "test" };
const navigation: Navigation = {
extras: {},
id: 0,
initialUrl: new UrlTree(),
extractedUrl: urlTree,
trigger: "imperative",
previousNavigation: undefined,
};
router.getCurrentNavigation.mockReturnValue(navigation);
await TestBed.runInInjectionContext(() => unauthUiRefreshRedirect("/redirect")());
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.UnauthenticatedExtensionUIRefresh,
);
expect(router.createUrlTree).toHaveBeenCalledWith(["/redirect"], {
queryParams: urlTree.queryParams,
});
});
});

View File

@@ -1,29 +0,0 @@
import { inject } from "@angular/core";
import { UrlTree, Router } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
/**
* Helper function to redirect to a new URL based on the UnauthenticatedExtensionUIRefresh feature flag.
* @param redirectUrl - The URL to redirect to if the UnauthenticatedExtensionUIRefresh flag is enabled.
*/
export function unauthUiRefreshRedirect(redirectUrl: string): () => Promise<boolean | UrlTree> {
return async () => {
const configService = inject(ConfigService);
const router = inject(Router);
const shouldRedirect = await configService.getFeatureFlag(
FeatureFlag.UnauthenticatedExtensionUIRefresh,
);
if (shouldRedirect) {
const currentNavigation = router.getCurrentNavigation();
const queryParams = currentNavigation?.extractedUrl?.queryParams || {};
// Preserve query params when redirecting as it is likely that the refreshed component
// will be consuming the same query params.
return router.createUrlTree([redirectUrl], { queryParams });
} else {
return true;
}
};
}

View File

@@ -0,0 +1,9 @@
import { Observable } from "rxjs";
export abstract class DeviceTrustToastService {
/**
* An observable pipeline that observes any cross-application toast messages
* that need to be shown as part of the trusted device encryption (TDE) process.
*/
abstract setupListeners$: Observable<void>;
}

View File

@@ -0,0 +1,44 @@
import { merge, Observable, tap } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
export class DeviceTrustToastService implements DeviceTrustToastServiceAbstraction {
private adminLoginApproved$: Observable<void>;
private deviceTrusted$: Observable<void>;
setupListeners$: Observable<void>;
constructor(
private authRequestService: AuthRequestServiceAbstraction,
private deviceTrustService: DeviceTrustServiceAbstraction,
private i18nService: I18nService,
private toastService: ToastService,
) {
this.adminLoginApproved$ = this.authRequestService.adminLoginApproved$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("loginApproved"),
});
}),
);
this.deviceTrusted$ = this.deviceTrustService.deviceTrusted$.pipe(
tap(() => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("deviceTrusted"),
});
}),
);
this.setupListeners$ = merge(this.adminLoginApproved$, this.deviceTrusted$);
}
}

View File

@@ -0,0 +1,167 @@
import { mock, MockProxy } from "jest-mock-extended";
import { EMPTY, of } from "rxjs";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "./device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "./device-trust-toast.service.implementation";
describe("DeviceTrustToastService", () => {
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
let i18nService: MockProxy<I18nService>;
let toastService: MockProxy<ToastService>;
let sut: DeviceTrustToastServiceAbstraction;
beforeEach(() => {
authRequestService = mock<AuthRequestServiceAbstraction>();
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
i18nService = mock<I18nService>();
toastService = mock<ToastService>();
i18nService.t.mockImplementation((key: string) => key); // just return the key that was given
});
const initService = () => {
return new DeviceTrustToastService(
authRequestService,
deviceTrustService,
i18nService,
toastService,
);
};
const loginApprovalToastOptions = {
variant: "success",
title: "",
message: "loginApproved",
};
const deviceTrustedToastOptions = {
variant: "success",
title: "",
message: "deviceTrusted",
};
describe("setupListeners$", () => {
describe("given adminLoginApproved$ emits and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ emits and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = of(undefined);
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ emits", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = of(undefined);
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
describe("given adminLoginApproved$ does not emit and deviceTrusted$ does not emit", () => {
beforeEach(() => {
// Arrange
authRequestService.adminLoginApproved$ = EMPTY;
deviceTrustService.deviceTrusted$ = EMPTY;
sut = initService();
});
it("should NOT trigger a toast for login approval", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(loginApprovalToastOptions); // Assert
done();
},
});
});
it("should NOT trigger a toast for device trust", (done) => {
// Act
sut.setupListeners$.subscribe({
complete: () => {
expect(toastService.showToast).not.toHaveBeenCalledWith(deviceTrustedToastOptions); // Assert
done();
},
});
});
});
});
});

View File

@@ -91,6 +91,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractio
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import {
InternalMasterPasswordServiceAbstraction,
MasterPasswordServiceAbstraction,
@@ -113,6 +114,7 @@ import { DeviceTrustService } from "@bitwarden/common/auth/services/device-trust
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service";
import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation";
import { MasterPasswordService } from "@bitwarden/common/auth/services/master-password/master-password.service";
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
@@ -304,6 +306,8 @@ import {
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
DefaultTaskService,
DefaultEndUserNotificationService,
EndUserNotificationService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
@@ -317,6 +321,8 @@ import {
IndividualVaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
import { ViewCacheService } from "../platform/abstractions/view-cache.service";
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
@@ -1281,7 +1287,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: BillingApiServiceAbstraction,
useClass: BillingApiService,
deps: [ApiServiceAbstraction, LogService, ToastService],
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: TaxServiceAbstraction,
@@ -1345,6 +1351,7 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultSetPasswordJitService,
deps: [
ApiServiceAbstraction,
MasterPasswordApiService,
KeyService,
EncryptService,
I18nServiceAbstraction,
@@ -1462,6 +1469,26 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultTaskService,
deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService],
}),
safeProvider({
provide: EndUserNotificationService,
useClass: DefaultEndUserNotificationService,
deps: [StateProvider, ApiServiceAbstraction],
}),
safeProvider({
provide: DeviceTrustToastServiceAbstraction,
useClass: DeviceTrustToastService,
deps: [
AuthRequestServiceAbstraction,
DeviceTrustServiceAbstraction,
I18nServiceAbstraction,
ToastService,
],
}),
safeProvider({
provide: MasterPasswordApiServiceAbstraction,
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
];
@NgModule({

View File

@@ -15,7 +15,6 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -41,7 +40,7 @@ import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
import { PasswordRepromptService } from "@bitwarden/vault";
import { PasswordRepromptService, SshImportPromptService } from "@bitwarden/vault";
@Directive()
export class AddEditComponent implements OnInit, OnDestroy {
@@ -131,7 +130,8 @@ export class AddEditComponent implements OnInit, OnDestroy {
protected configService: ConfigService,
protected cipherAuthorizationService: CipherAuthorizationService,
protected toastService: ToastService,
private sdkService: SdkService,
protected sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
) {
this.typeOptions = [
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
@@ -207,10 +207,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.writeableCollections = await this.loadCollections();
this.canUseReprompt = await this.passwordRepromptService.enabled();
const sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
if (sshKeysEnabled) {
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
ngOnDestroy() {
@@ -824,6 +821,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
return true;
}
async importSshKeyFromClipboard() {
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
if (key != null) {
this.cipher.sshKey.privateKey = key.privateKey;
this.cipher.sshKey.publicKey = key.publicKey;
this.cipher.sshKey.keyFingerprint = key.keyFingerprint;
}
}
private async generateSshKey(showNotification: boolean = true) {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, Output } from "@angular/core";
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { CollectionView } from "@bitwarden/admin-console/common";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
@@ -10,7 +10,7 @@ import { TopLevelTreeNode } from "../models/top-level-tree-node.model";
import { VaultFilter } from "../models/vault-filter.model";
@Directive()
export class CollectionFilterComponent {
export class CollectionFilterComponent implements OnInit {
@Input() hide = false;
@Input() collapsedFilterNodes: Set<string>;
@Input() collectionNodes: DynamicTreeNode<CollectionView>;
@@ -51,4 +51,13 @@ export class CollectionFilterComponent {
async toggleCollapse(node: ITreeNodeObject) {
this.onNodeCollapseStateChange.emit(node);
}
ngOnInit() {
// Populate the set with all node IDs so all nodes are collapsed initially.
if (this.collectionNodes?.fullList) {
this.collectionNodes.fullList.forEach((node) => {
this.collapsedFilterNodes.add(node.id);
});
}
}
}

View File

@@ -13,6 +13,8 @@
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/importer/core": ["../importer/src"],
"@bitwarden/importer-ui": ["../importer/src/components"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from "@angular/forms";
import { ActivatedRoute, Router, RouterModule } from "@angular/router";
import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs";
import { firstValueFrom, Subject, take, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -19,11 +19,9 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { ClientType, HttpStatusCode } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
@@ -121,7 +119,6 @@ export class LoginComponent implements OnInit, OnDestroy {
private toastService: ToastService,
private logService: LogService,
private validationService: ValidationService,
private configService: ConfigService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
) {
this.clientType = this.platformUtilsService.getClientType();
@@ -131,9 +128,6 @@ export class LoginComponent implements OnInit, OnDestroy {
// Add popstate listener to listen for browser back button clicks
window.addEventListener("popstate", this.handlePopState);
// TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed.
this.listenForUnauthUiRefreshFlagChanges();
await this.defaultOnInit();
if (this.clientType === ClientType.Desktop) {
@@ -154,30 +148,6 @@ export class LoginComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private listenForUnauthUiRefreshFlagChanges() {
this.configService
.getFeatureFlag$(FeatureFlag.UnauthenticatedExtensionUIRefresh)
.pipe(
tap(async (flag) => {
// If the flag is turned OFF, we must force a reload to ensure the correct UI is shown
if (!flag) {
const qParams = await firstValueFrom(this.activatedRoute.queryParams);
const uniqueQueryParams = {
...qParams,
// adding a unique timestamp to the query params to force a reload
t: new Date().getTime().toString(), // Adding a unique timestamp as a query parameter
};
await this.router.navigate(["/"], {
queryParams: uniqueQueryParams,
});
}
}),
takeUntil(this.destroy$),
)
.subscribe();
}
submit = async (): Promise<void> => {
if (this.clientType === ClientType.Desktop) {
if (this.loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY) {

View File

@@ -9,6 +9,7 @@ import {
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationKeysResponse } from "@bitwarden/common/admin-console/models/response/organization-keys.response";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
@@ -31,6 +32,7 @@ describe("DefaultSetPasswordJitService", () => {
let sut: DefaultSetPasswordJitService;
let apiService: MockProxy<ApiService>;
let masterPasswordApiService: MockProxy<MasterPasswordApiService>;
let keyService: MockProxy<KeyService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
@@ -42,6 +44,7 @@ describe("DefaultSetPasswordJitService", () => {
beforeEach(() => {
apiService = mock<ApiService>();
masterPasswordApiService = mock<MasterPasswordApiService>();
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
@@ -53,6 +56,7 @@ describe("DefaultSetPasswordJitService", () => {
sut = new DefaultSetPasswordJitService(
apiService,
masterPasswordApiService,
keyService,
encryptService,
i18nService,
@@ -148,7 +152,7 @@ describe("DefaultSetPasswordJitService", () => {
keyService.makeKeyPair.mockResolvedValue(keyPair);
apiService.setPassword.mockResolvedValue(undefined);
masterPasswordApiService.setPassword.mockResolvedValue(undefined);
masterPasswordService.setForceSetPasswordReason.mockResolvedValue(undefined);
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
@@ -185,7 +189,7 @@ describe("DefaultSetPasswordJitService", () => {
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should set password successfully (given no user key)", async () => {
@@ -196,7 +200,7 @@ describe("DefaultSetPasswordJitService", () => {
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
});
it("should handle reset password auto enroll", async () => {
@@ -210,7 +214,7 @@ describe("DefaultSetPasswordJitService", () => {
await sut.setPassword(credentials);
// Assert
expect(apiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(organizationApiService.getKeys).toHaveBeenCalledWith(orgId);
expect(encryptService.rsaEncrypt).toHaveBeenCalledWith(userKey.key, orgPublicKey);
expect(

View File

@@ -9,6 +9,7 @@ import {
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SetPasswordRequest } from "@bitwarden/common/auth/models/request/set-password.request";
@@ -29,6 +30,7 @@ import {
export class DefaultSetPasswordJitService implements SetPasswordJitService {
constructor(
protected apiService: ApiService,
protected masterPasswordApiService: MasterPasswordApiService,
protected keyService: KeyService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
@@ -77,7 +79,7 @@ export class DefaultSetPasswordJitService implements SetPasswordJitService {
kdfConfig.iterations,
);
await this.apiService.setPassword(request);
await this.masterPasswordApiService.setPassword(request);
// Clear force set password reason to allow navigation back to vault.
await this.masterPasswordService.setForceSetPasswordReason(ForceSetPasswordReason.None, userId);

View File

@@ -226,20 +226,6 @@ describe("TwoFactorAuthComponent", () => {
});
};
const testForceResetOnSuccessfulLogin = (reasonString: string) => {
it(`navigates to the component's defined forcePasswordResetRoute route when response.forcePasswordReset is ${reasonString}`, async () => {
// Act
await component.submit("testToken");
// expect(mockRouter.navigate).toHaveBeenCalledTimes(1);
expect(mockRouter.navigate).toHaveBeenCalledWith(["update-temp-password"], {
queryParams: {
identifier: component.orgSsoIdentifier,
},
});
});
};
describe("Standard 2FA scenarios", () => {
describe("submit", () => {
const token = "testToken";
@@ -311,26 +297,6 @@ describe("TwoFactorAuthComponent", () => {
});
});
describe("Force Master Password Reset scenarios", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
it("navigates to the component's defined success route (vault is default) when the login is successful", async () => {
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
@@ -407,29 +373,7 @@ describe("TwoFactorAuthComponent", () => {
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is required", () => {
[
ForceSetPasswordReason.AdminForcePasswordReset,
ForceSetPasswordReason.WeakMasterPassword,
].forEach((forceResetPasswordReason) => {
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
beforeEach(() => {
// use standard user with MP because this test is not concerned with password reset.
selectedUserDecryptionOptions.next(
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
);
const authResult = new AuthResult();
authResult.forcePasswordReset = forceResetPasswordReason;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});
testForceResetOnSuccessfulLogin(reasonString);
});
});
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
describe("Given Trusted Device Encryption is enabled and user doesn't need to set a MP", () => {
let authResult;
beforeEach(() => {
selectedUserDecryptionOptions.next(
@@ -437,7 +381,6 @@ describe("TwoFactorAuthComponent", () => {
);
authResult = new AuthResult();
authResult.forcePasswordReset = ForceSetPasswordReason.None;
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult);
});

View File

@@ -396,11 +396,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
);
}
// note: this flow affects both TDE & standard users
if (this.isForcePasswordResetRequired(authResult)) {
return await this.handleForcePasswordReset(this.orgSsoIdentifier);
}
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
);
@@ -415,6 +410,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
const requireSetPassword =
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
// New users without a master password must set a master password before advancing.
if (requireSetPassword || authResult.resetMasterPassword) {
// Change implies going no password -> password in this case
return await this.handleChangePasswordRequired(this.orgSsoIdentifier);
@@ -524,14 +520,6 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
return forceResetReasons.includes(authResult.forcePasswordReset);
}
private async handleForcePasswordReset(orgIdentifier: string | undefined) {
await this.router.navigate(["update-temp-password"], {
queryParams: {
identifier: orgIdentifier,
},
});
}
showContinueButton() {
return (
this.selectedProviderType != null &&

View File

@@ -12,6 +12,12 @@ export abstract class AuthRequestServiceAbstraction {
/** Emits an auth request id when an auth request has been approved. */
authRequestPushNotification$: Observable<string>;
/**
* Emits when a login has been approved by an admin. This emission is specifically for the
* purpose of notifying the consuming component to display a toast informing the user.
*/
adminLoginApproved$: Observable<void>;
/**
* Returns an admin auth request for the given user if it exists.
* @param userId The user id.
@@ -106,4 +112,13 @@ export abstract class AuthRequestServiceAbstraction {
* @returns The dash-delimited fingerprint phrase.
*/
abstract getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string>;
/**
* Passes a value to the adminLoginApprovedSubject via next(), which causes the
* adminLoginApproved$ observable to emit.
*
* The purpose is to notify consuming components (of adminLoginApproved$) to display
* a toast informing the user that a login has been approved by an admin.
*/
abstract emitAdminLoginApproved(): void;
}

View File

@@ -306,6 +306,31 @@ describe("LoginStrategy", () => {
expect(result).toEqual(expected);
});
it("processes a forcePasswordReset response properly", async () => {
const tokenResponse = identityTokenResponseFactory();
tokenResponse.forcePasswordReset = true;
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
const result = await passwordLoginStrategy.logIn(credentials);
const expected = new AuthResult();
expected.userId = userId;
expected.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
expected.resetMasterPassword = false;
expected.twoFactorProviders = {} as Partial<
Record<TwoFactorProviderType, Record<string, string>>
>;
expected.captchaSiteKey = "";
expected.twoFactorProviders = null;
expect(result).toEqual(expected);
expect(masterPasswordService.mock.setForceSetPasswordReason).toHaveBeenCalledWith(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
);
});
it("rejects login if CAPTCHA is required", async () => {
// Sample CAPTCHA response
const tokenResponse = new IdentityCaptchaResponse({

View File

@@ -271,17 +271,24 @@ export abstract class LoginStrategy {
}
}
result.resetMasterPassword = response.resetMasterPassword;
// Convert boolean to enum
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
}
// Must come before setting keys, user key needs email to update additional keys
// Must come before setting keys, user key needs email to update additional keys.
const userId = await this.saveAccountInformation(response);
result.userId = userId;
result.resetMasterPassword = response.resetMasterPassword;
// Convert boolean to enum and set the state for the master password service to
// so we know when we reach the auth guard that we need to guide them properly to admin
// password reset.
if (response.forcePasswordReset) {
result.forcePasswordReset = ForceSetPasswordReason.AdminForcePasswordReset;
await this.masterPasswordService.setForceSetPasswordReason(
ForceSetPasswordReason.AdminForcePasswordReset,
userId,
);
}
if (response.twoFactorToken != null) {
// note: we can read email from access token b/c it was saved in saveAccountInformation
const userEmail = await this.tokenService.getEmail();
@@ -300,7 +307,9 @@ export abstract class LoginStrategy {
// The keys comes from different sources depending on the login strategy
protected abstract setMasterKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setUserKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
protected abstract setPrivateKey(response: IdentityTokenResponse, userId: UserId): Promise<void>;
// Old accounts used master key for encryption. We are forcing migrations but only need to

View File

@@ -6,7 +6,6 @@ import { Jsonify } from "type-fest";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { SsoTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/sso-token.request";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
@@ -108,14 +107,6 @@ export class SsoLoginStrategy extends LoginStrategy {
const email = ssoAuthResult.email;
const ssoEmail2FaSessionToken = ssoAuthResult.ssoEmail2FaSessionToken;
// Auth guard currently handles redirects for this.
if (ssoAuthResult.forcePasswordReset == ForceSetPasswordReason.AdminForcePasswordReset) {
await this.masterPasswordService.setForceSetPasswordReason(
ssoAuthResult.forcePasswordReset,
ssoAuthResult.userId,
);
}
this.cache.next({
...this.cache.value,
email,
@@ -278,7 +269,8 @@ export class SsoLoginStrategy extends LoginStrategy {
// TODO: eventually we post and clean up DB as well once consumed on client
await this.authRequestService.clearAdminAuthRequest(userId);
this.platformUtilsService.showToast("success", null, this.i18nService.t("loginApproved"));
// This notification will be picked up by the SsoComponent to handle displaying a toast to the user
this.authRequestService.emitAdminLoginApproved();
}
}
}

View File

@@ -43,6 +43,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private authRequestPushNotificationSubject = new Subject<string>();
authRequestPushNotification$: Observable<string>;
// Observable emission is used to trigger a toast in consuming components
private adminLoginApprovedSubject = new Subject<void>();
adminLoginApproved$: Observable<void>;
constructor(
private appIdService: AppIdService,
private accountService: AccountService,
@@ -53,6 +57,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
private stateProvider: StateProvider,
) {
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
this.adminLoginApproved$ = this.adminLoginApprovedSubject.asObservable();
}
async getAdminAuthRequest(userId: UserId): Promise<AdminAuthRequestStorable | null> {
@@ -207,4 +212,8 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
async getFingerprintPhrase(email: string, publicKey: Uint8Array): Promise<string> {
return (await this.keyService.getFingerprint(email.toLowerCase(), publicKey)).join("-");
}
emitAdminLoginApproved(): void {
this.adminLoginApprovedSubject.next();
}
}

View File

@@ -38,7 +38,6 @@ import {
ProviderUserUserDetailsResponse,
} from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@@ -49,17 +48,13 @@ import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
@@ -169,8 +164,6 @@ export abstract class ApiService {
postPrelogin: (request: PreloginRequest) => Promise<PreloginResponse>;
postEmailToken: (request: EmailTokenRequest) => Promise<any>;
postEmail: (request: EmailRequest) => Promise<any>;
postPassword: (request: PasswordRequest) => Promise<any>;
setPassword: (request: SetPasswordRequest) => Promise<any>;
postSetKeyConnectorKey: (request: SetKeyConnectorKeyRequest) => Promise<any>;
postSecurityStamp: (request: SecretVerificationRequest) => Promise<any>;
getAccountRevisionDate: () => Promise<number>;
@@ -189,13 +182,8 @@ export abstract class ApiService {
postAccountKdf: (request: KdfRequest) => Promise<any>;
postUserApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
postUserRotateApiKey: (id: string, request: SecretVerificationRequest) => Promise<ApiKeyResponse>;
putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
putUpdateTdeOffboardingPassword: (request: UpdateTdeOffboardingPasswordRequest) => Promise<any>;
postConvertToKeyConnector: () => Promise<void>;
//passwordless
postAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
postAdminAuthRequest: (request: AuthRequest) => Promise<AuthRequestResponse>;
getAuthResponse: (id: string, accessCode: string) => Promise<AuthRequestResponse>;
getAuthRequest: (id: string) => Promise<AuthRequestResponse>;
putAuthRequest: (id: string, request: PasswordlessAuthRequest) => Promise<AuthRequestResponse>;
getAuthRequests: () => Promise<ListResponse<AuthRequestResponse>>;

View File

@@ -16,6 +16,12 @@ export abstract class DeviceTrustServiceAbstraction {
*/
supportsDeviceTrust$: Observable<boolean>;
/**
* Emits when a device has been trusted. This emission is specifically for the purpose of notifying
* the consuming component to display a toast informing the user the device has been trusted.
*/
deviceTrusted$: Observable<void>;
/**
* @description Checks if the device trust feature is supported for the given user.
*/

View File

@@ -0,0 +1,28 @@
import { PasswordRequest } from "../models/request/password.request";
import { SetPasswordRequest } from "../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../models/request/update-temp-password.request";
export abstract class MasterPasswordApiService {
/**
* POSTs a SetPasswordRequest to "/accounts/set-password"
*/
abstract setPassword: (request: SetPasswordRequest) => Promise<any>;
/**
* POSTs a PasswordRequest to "/accounts/password"
*/
abstract postPassword: (request: PasswordRequest) => Promise<any>;
/**
* PUTs an UpdateTempPasswordRequest to "/accounts/update-temp-password"
*/
abstract putUpdateTempPassword: (request: UpdateTempPasswordRequest) => Promise<any>;
/**
* PUTs an UpdateTdeOffboardingPasswordRequest to "/accounts/update-tde-offboarding-password"
*/
abstract putUpdateTdeOffboardingPassword: (
request: UpdateTdeOffboardingPasswordRequest,
) => Promise<any>;
}

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable } from "rxjs";
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { KeyService } from "@bitwarden/key-management";
@@ -63,6 +63,10 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
supportsDeviceTrust$: Observable<boolean>;
// Observable emission is used to trigger a toast in consuming components
private deviceTrustedSubject = new Subject<void>();
deviceTrusted$ = this.deviceTrustedSubject.asObservable();
constructor(
private keyGenerationService: KeyGenerationService,
private cryptoFunctionService: CryptoFunctionService,
@@ -177,7 +181,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
// store device key in local/secure storage if enc keys posted to server successfully
await this.setDeviceKey(userId, deviceKey);
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
// This emission will be picked up by consuming components to handle displaying a toast to the user
this.deviceTrustedSubject.next();
return deviceResponse;
}

View File

@@ -0,0 +1,85 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from "../../abstractions/master-password-api.service.abstraction";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
export class MasterPasswordApiService implements MasterPasswordApiServiceAbstraction {
constructor(
private apiService: ApiService,
private logService: LogService,
) {}
async setPassword(request: SetPasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/set-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async postPassword(request: PasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"POST",
"/accounts/password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
try {
const response = await this.apiService.send(
"PUT",
"/accounts/update-temp-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
async putUpdateTdeOffboardingPassword(
request: UpdateTdeOffboardingPasswordRequest,
): Promise<any> {
try {
const response = await this.apiService.send(
"PUT",
"/accounts/update-tde-offboarding-password",
request,
true,
false,
);
return response;
} catch (e: unknown) {
this.logService.error(e);
throw e;
}
}
}

View File

@@ -0,0 +1,130 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { KdfType } from "@bitwarden/key-management";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
import { UpdateTdeOffboardingPasswordRequest } from "../../models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../../models/request/update-temp-password.request";
import { MasterPasswordApiService } from "./master-password-api.service.implementation";
describe("MasterPasswordApiService", () => {
let apiService: MockProxy<ApiService>;
let logService: MockProxy<LogService>;
let sut: MasterPasswordApiService;
beforeEach(() => {
apiService = mock<ApiService>();
logService = mock<LogService>();
sut = new MasterPasswordApiService(apiService, logService);
});
it("should instantiate", () => {
expect(sut).not.toBeFalsy();
});
describe("setPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = new SetPasswordRequest(
"masterPasswordHash",
"key",
"masterPasswordHint",
"orgIdentifier",
{
publicKey: "publicKey",
encryptedPrivateKey: "encryptedPrivateKey",
},
KdfType.PBKDF2_SHA256,
600_000,
);
// Act
await sut.setPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"POST",
"/accounts/set-password",
request,
true,
false,
);
});
});
describe("postPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
newMasterPasswordHash: "newMasterPasswordHash",
masterPasswordHint: "masterPasswordHint",
key: "key",
masterPasswordHash: "masterPasswordHash",
} as PasswordRequest;
// Act
await sut.postPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"POST",
"/accounts/password",
request,
true,
false,
);
});
});
describe("putUpdateTempPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
masterPasswordHint: "masterPasswordHint",
newMasterPasswordHash: "newMasterPasswordHash",
key: "key",
} as UpdateTempPasswordRequest;
// Act
await sut.putUpdateTempPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/accounts/update-temp-password",
request,
true,
false,
);
});
});
describe("putUpdateTdeOffboardingPassword", () => {
it("should call apiService.send with the correct parameters", async () => {
// Arrange
const request = {
masterPasswordHint: "masterPasswordHint",
newMasterPasswordHash: "newMasterPasswordHash",
key: "key",
} as UpdateTdeOffboardingPasswordRequest;
// Act
await sut.putUpdateTdeOffboardingPassword(request);
// Assert
expect(apiService.send).toHaveBeenCalledWith(
"PUT",
"/accounts/update-tde-offboarding-password",
request,
true,
false,
);
});
});
});

View File

@@ -1,13 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ToastService } from "@bitwarden/components";
import { ApiService } from "../../abstractions/api.service";
import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request";
import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response";
import { ErrorResponse } from "../../models/response/error.response";
import { ListResponse } from "../../models/response/list.response";
import { LogService } from "../../platform/abstractions/log.service";
import { BillingApiServiceAbstraction } from "../abstractions";
import { PaymentMethodType } from "../enums";
import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request";
@@ -23,11 +20,7 @@ import { PlanResponse } from "../models/response/plan.response";
import { ProviderSubscriptionResponse } from "../models/response/provider-subscription-response";
export class BillingApiService implements BillingApiServiceAbstraction {
constructor(
private apiService: ApiService,
private logService: LogService,
private toastService: ToastService,
) {}
constructor(private apiService: ApiService) {}
cancelOrganizationSubscription(
organizationId: string,
@@ -89,14 +82,12 @@ export class BillingApiService implements BillingApiServiceAbstraction {
}
async getOrganizationPaymentMethod(organizationId: string): Promise<PaymentMethodResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/payment-method",
null,
true,
true,
);
return new PaymentMethodResponse(response);
}
@@ -120,34 +111,34 @@ export class BillingApiService implements BillingApiServiceAbstraction {
async getProviderClientOrganizations(
providerId: string,
): Promise<ListResponse<ProviderOrganizationOrganizationDetailsResponse>> {
const response = await this.execute(() =>
this.apiService.send("GET", "/providers/" + providerId + "/organizations", null, true, true),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/organizations",
null,
true,
true,
);
return new ListResponse(response, ProviderOrganizationOrganizationDetailsResponse);
}
async getProviderInvoices(providerId: string): Promise<InvoicesResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/invoices",
null,
true,
true,
);
return new InvoicesResponse(response);
}
async getProviderSubscription(providerId: string): Promise<ProviderSubscriptionResponse> {
const response = await this.execute(() =>
this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/subscription",
null,
true,
true,
),
const response = await this.apiService.send(
"GET",
"/providers/" + providerId + "/billing/subscription",
null,
true,
true,
);
return new ProviderSubscriptionResponse(response);
}
@@ -227,20 +218,4 @@ export class BillingApiService implements BillingApiServiceAbstraction {
false,
);
}
private async execute(request: () => Promise<any>): Promise<any> {
try {
return await request();
} catch (error) {
this.logService.error(error);
if (error instanceof ErrorResponse) {
this.toastService.showToast({
variant: "error",
title: null,
message: error.getSingleMessage(),
});
}
throw error;
}
}
}

View File

@@ -17,7 +17,6 @@ export enum FeatureFlag {
IdpAutoSubmitLogin = "idp-auto-submit-login",
InlineMenuFieldQualification = "inline-menu-field-qualification",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
InlineMenuTotp = "inline-menu-totp",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
NotificationRefresh = "notification-refresh",
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
@@ -26,19 +25,20 @@ export enum FeatureFlag {
ItemShare = "item-share",
CriticalApps = "pm-14466-risk-insights-critical-application",
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
DesktopSendUIRefresh = "desktop-send-ui-refresh",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
VaultBulkManagementAction = "vault-bulk-management-action",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
SecurityTasks = "security-tasks",
/* Vault */
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
CipherKeyEncryption = "cipher-key-encryption",
TrialPaymentOptional = "PM-8163-trial-payment",
MacOsNativeCredentialSync = "macos-native-credential-sync",
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
@@ -72,7 +72,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.InlineMenuTotp]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.NotificationRefresh]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -81,19 +80,20 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
/* Vault */
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,

View File

@@ -36,7 +36,6 @@ export abstract class EncryptService {
): Promise<Uint8Array | null>;
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
abstract resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey;
/**
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
* @param items The items to decrypt

View File

@@ -78,8 +78,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No key provided for decryption.");
}
key = this.resolveLegacyKey(key, encString);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encString?.mac == null) {
this.logService.error(
@@ -145,8 +143,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("Nothing provided for decryption.");
}
key = this.resolveLegacyKey(key, encThing);
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
if (key.macKey != null && encThing.macBytes == null) {
this.logService.error(
@@ -298,19 +294,4 @@ export class EncryptServiceImplementation implements EncryptService {
this.logService.error(msg);
}
}
/**
* Transform into new key for the old encrypt-then-mac scheme if required, otherwise return the current key unchanged
* @param encThing The encrypted object (e.g. encString or encArrayBuffer) that you want to decrypt
*/
resolveLegacyKey(key: SymmetricCryptoKey, encThing: Encrypted): SymmetricCryptoKey {
if (
encThing.encryptionType === EncryptionType.AesCbc128_HmacSha256_B64 &&
key.encType === EncryptionType.AesCbc256_B64
) {
return new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
}
return key;
}
}

View File

@@ -325,6 +325,25 @@ describe("EncryptService", () => {
});
});
describe("decryptToUtf8", () => {
it("throws if no key is provided", () => {
return expect(encryptService.decryptToUtf8(null, null)).rejects.toThrow(
"No key provided for decryption.",
);
});
it("returns null if key is mac key but encstring has no mac", async () => {
const key = new SymmetricCryptoKey(
makeStaticByteArray(64, 0),
EncryptionType.AesCbc256_HmacSha256_B64,
);
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
const actual = await encryptService.decryptToUtf8(encString, key);
expect(actual).toBeNull();
expect(logService.error).toHaveBeenCalled();
});
});
describe("rsa", () => {
const data = makeStaticByteArray(10, 100);
const encryptedData = makeStaticByteArray(10, 150);
@@ -370,17 +389,16 @@ describe("EncryptService", () => {
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
});
it.each([
EncryptionType.AesCbc256_B64,
EncryptionType.AesCbc128_HmacSha256_B64,
EncryptionType.AesCbc256_HmacSha256_B64,
])("throws if encryption type is %s", async (encType) => {
encString.encryptionType = encType;
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
"throws if encryption type is %s",
async (encType) => {
encString.encryptionType = encType;
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
});
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
"Invalid encryption type",
);
},
);
it("decrypts data with provided key", async () => {
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
@@ -398,30 +416,6 @@ describe("EncryptService", () => {
});
});
describe("resolveLegacyKey", () => {
it("creates a legacy key if required", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(32), EncryptionType.AesCbc256_B64);
const encString = mock<EncString>();
encString.encryptionType = EncryptionType.AesCbc128_HmacSha256_B64;
const actual = encryptService.resolveLegacyKey(key, encString);
const expected = new SymmetricCryptoKey(key.key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(actual).toEqual(expected);
});
it("does not create a legacy key if not required", async () => {
const encType = EncryptionType.AesCbc256_HmacSha256_B64;
const key = new SymmetricCryptoKey(makeStaticByteArray(64), encType);
const encString = mock<EncString>();
encString.encryptionType = encType;
const actual = encryptService.resolveLegacyKey(key, encString);
expect(actual).toEqual(key);
});
});
describe("hash", () => {
it("hashes a string and returns b64", async () => {
cryptoFunctionService.hash.mockResolvedValue(Uint8Array.from([1, 2, 3]));

View File

@@ -73,6 +73,7 @@ export class CipherExport {
break;
case CipherType.SshKey:
view.sshKey = SshKeyExport.toView(req.sshKey);
break;
}
if (req.passwordHistory != null) {

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { import_ssh_key } from "@bitwarden/sdk-internal";
import { EncString } from "../../platform/models/domain/enc-string";
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
@@ -17,16 +18,18 @@ export class SshKeyExport {
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;
const parsedKey = import_ssh_key(req.privateKey);
view.privateKey = parsedKey.privateKey;
view.publicKey = parsedKey.publicKey;
view.keyFingerprint = parsedKey.fingerprint;
return view;
}
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
domain.privateKey = req.privateKey != null ? new EncString(req.privateKey) : null;
domain.publicKey = req.publicKey != null ? new EncString(req.publicKey) : null;
domain.keyFingerprint = req.keyFingerprint != null ? new EncString(req.keyFingerprint) : null;
const parsedKey = import_ssh_key(req.privateKey);
domain.privateKey = new EncString(parsedKey.privateKey);
domain.publicKey = new EncString(parsedKey.publicKey);
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
return domain;
}

View File

@@ -1,6 +1,6 @@
export enum EncryptionType {
AesCbc256_B64 = 0,
AesCbc128_HmacSha256_B64 = 1,
// Type 1 was the unused and removed AesCbc128_HmacSha256_B64
AesCbc256_HmacSha256_B64 = 2,
Rsa2048_OaepSha256_B64 = 3,
Rsa2048_OaepSha1_B64 = 4,
@@ -17,12 +17,10 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
}
/** The expected number of parts to a serialized EncString of the given encryption type.
* For example, an EncString of type AesCbc256_B64 will have 2 parts, and an EncString of type
* AesCbc128_HmacSha256_B64 will have 3 parts.
* For example, an EncString of type AesCbc256_B64 will have 2 parts
*
* Example of annotated serialized EncStrings:
* 0.iv|data
* 1.iv|data|mac
* 2.iv|data|mac
* 3.data
* 4.data
@@ -33,7 +31,6 @@ export function encryptionTypeToString(encryptionType: EncryptionType): string {
*/
export const EXPECTED_NUM_PARTS_BY_ENCRYPTION_TYPE = {
[EncryptionType.AesCbc256_B64]: 2,
[EncryptionType.AesCbc128_HmacSha256_B64]: 3,
[EncryptionType.AesCbc256_HmacSha256_B64]: 3,
[EncryptionType.Rsa2048_OaepSha256_B64]: 1,
[EncryptionType.Rsa2048_OaepSha1_B64]: 1,

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ConditionalExcept, ConditionalKeys, Constructor } from "type-fest";
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
@@ -15,6 +13,19 @@ export type DecryptedObject<
TDecryptedKeys extends EncStringKeys<TEncryptedObject>,
> = Record<TDecryptedKeys, string> & Omit<TEncryptedObject, TDecryptedKeys>;
// extracts shared keys from the domain and view types
type EncryptableKeys<D extends Domain, V extends View> = (keyof D &
ConditionalKeys<D, EncString | null>) &
(keyof V & ConditionalKeys<V, string | null>);
type DomainEncryptableKeys<D extends Domain> = {
[key in ConditionalKeys<D, EncString | null>]: EncString | null;
};
type ViewEncryptableKeys<V extends View> = {
[key in ConditionalKeys<V, string | null>]: string | null;
};
// https://contributing.bitwarden.com/architecture/clients/data-model#domain
export default class Domain {
protected buildDomainModel<D extends Domain>(
@@ -37,6 +48,7 @@ export default class Domain {
}
}
}
protected buildDataModel<D extends Domain>(
domain: D,
dataObj: any,
@@ -58,31 +70,24 @@ export default class Domain {
}
}
protected async decryptObj<T extends View>(
viewModel: T,
map: any,
orgId: string,
key: SymmetricCryptoKey = null,
protected async decryptObj<D extends Domain, V extends View>(
domain: DomainEncryptableKeys<D>,
viewModel: ViewEncryptableKeys<V>,
props: EncryptableKeys<D, V>[],
orgId: string | null,
key: SymmetricCryptoKey | null = null,
objectContext: string = "No Domain Context",
): Promise<T> {
const self: any = this;
for (const prop in map) {
// eslint-disable-next-line
if (!map.hasOwnProperty(prop)) {
continue;
}
const mapProp = map[prop] || prop;
if (self[mapProp]) {
(viewModel as any)[prop] = await self[mapProp].decrypt(
): Promise<V> {
for (const prop of props) {
viewModel[prop] =
(await domain[prop]?.decrypt(
orgId,
key,
`Property: ${prop}; ObjectContext: ${objectContext}`,
);
}
`Property: ${prop as string}; ObjectContext: ${objectContext}`,
)) ?? null;
}
return viewModel;
return viewModel as V;
}
/**
@@ -111,7 +116,7 @@ export default class Domain {
const decryptedObjects = [];
for (const prop of encryptedProperties) {
const value = (this as any)[prop] as EncString;
const value = this[prop] as EncString;
const decrypted = await this.decryptProperty(
prop,
value,
@@ -138,11 +143,9 @@ export default class Domain {
encryptService: EncryptService,
decryptTrace: string,
) {
let decrypted: string = null;
let decrypted: string | null = null;
if (value) {
decrypted = await value.decryptWithKey(key, encryptService, decryptTrace);
} else {
decrypted = null;
}
return {
[propertyKey]: decrypted,

View File

@@ -5,28 +5,28 @@ import { EncArrayBuffer } from "./enc-array-buffer";
describe("encArrayBuffer", () => {
describe("parses the buffer", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"],
])("with %c%s", (encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
test.each([[EncryptionType.AesCbc256_HmacSha256_B64, "AesCbc256_HmacSha256_B64"]])(
"with %c%s",
(encType: EncryptionType) => {
const iv = makeStaticByteArray(16, 10);
const mac = makeStaticByteArray(32, 20);
// We use the minimum data length of 1 to test the boundary of valid lengths
const data = makeStaticByteArray(1, 100);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const array = new Uint8Array(1 + iv.byteLength + mac.byteLength + data.byteLength);
array.set([encType]);
array.set(iv, 1);
array.set(mac, 1 + iv.byteLength);
array.set(data, 1 + iv.byteLength + mac.byteLength);
const actual = new EncArrayBuffer(array);
const actual = new EncArrayBuffer(array);
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
});
expect(actual.encryptionType).toEqual(encType);
expect(actual.ivBytes).toEqualBuffer(iv);
expect(actual.macBytes).toEqualBuffer(mac);
expect(actual.dataBytes).toEqualBuffer(data);
},
);
it("with AesCbc256_B64", () => {
const encType = EncryptionType.AesCbc256_B64;
@@ -50,7 +50,6 @@ describe("encArrayBuffer", () => {
describe("throws if the buffer has an invalid length", () => {
test.each([
[EncryptionType.AesCbc128_HmacSha256_B64, 50, "AesCbc128_HmacSha256_B64"],
[EncryptionType.AesCbc256_HmacSha256_B64, 50, "AesCbc256_HmacSha256_B64"],
[EncryptionType.AesCbc256_B64, 18, "AesCbc256_B64"],
])("with %c%c%s", (encType: EncryptionType, minLength: number) => {

View File

@@ -20,7 +20,6 @@ export class EncArrayBuffer implements Encrypted {
const encType = encBytes[0];
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:
case EncryptionType.AesCbc256_HmacSha256_B64: {
const minimumLength = ENC_TYPE_LENGTH + IV_LENGTH + MAC_LENGTH + MIN_DATA_LENGTH;
if (encBytes.length < minimumLength) {

View File

@@ -60,9 +60,7 @@ describe("EncString", () => {
const cases = [
"aXY=|Y3Q=", // AesCbc256_B64 w/out header
"aXY=|Y3Q=|cnNhQ3Q=", // AesCbc128_HmacSha256_B64 w/out header
"0.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_B64 with header
"1.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc128_HmacSha256_B64
"2.QmFzZTY0UGFydA==|QmFzZTY0UGFydA==|QmFzZTY0UGFydA==", // AesCbc256_HmacSha256_B64
"3.QmFzZTY0UGFydA==", // Rsa2048_OaepSha256_B64
"4.QmFzZTY0UGFydA==", // Rsa2048_OaepSha1_B64

View File

@@ -89,7 +89,6 @@ export class EncString implements Encrypted {
}
switch (encType) {
case EncryptionType.AesCbc128_HmacSha256_B64:
case EncryptionType.AesCbc256_HmacSha256_B64:
this.iv = encPieces[0];
this.data = encPieces[1];
@@ -132,10 +131,7 @@ export class EncString implements Encrypted {
}
} else {
encPieces = encryptedString.split("|");
encType =
encPieces.length === 3
? EncryptionType.AesCbc128_HmacSha256_B64
: EncryptionType.AesCbc256_B64;
encType = EncryptionType.AesCbc256_B64;
}
return {
@@ -160,7 +156,7 @@ export class EncString implements Encrypted {
async decrypt(
orgId: string | null,
key: SymmetricCryptoKey = null,
key: SymmetricCryptoKey | null = null,
context?: string,
): Promise<string> {
if (this.decryptedValue != null) {

View File

@@ -27,21 +27,6 @@ describe("SymmetricCryptoKey", () => {
});
});
it("AesCbc128_HmacSha256_B64", () => {
const key = makeStaticByteArray(32);
const cryptoKey = new SymmetricCryptoKey(key, EncryptionType.AesCbc128_HmacSha256_B64);
expect(cryptoKey).toEqual({
encKey: key.slice(0, 16),
encKeyB64: "AAECAwQFBgcICQoLDA0ODw==",
encType: 1,
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
macKey: key.slice(16, 32),
macKeyB64: "EBESExQVFhcYGRobHB0eHw==",
});
});
it("AesCbc256_HmacSha256_B64", () => {
const key = makeStaticByteArray(64);
const cryptoKey = new SymmetricCryptoKey(key);

View File

@@ -38,9 +38,6 @@ export class SymmetricCryptoKey {
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
this.encKey = key;
this.macKey = null;
} else if (encType === EncryptionType.AesCbc128_HmacSha256_B64 && key.byteLength === 32) {
this.encKey = key.slice(0, 16);
this.macKey = key.slice(16, 32);
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
this.encKey = key.slice(0, 32);
this.macKey = key.slice(32, 64);

View File

@@ -201,3 +201,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition(
export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");

View File

@@ -43,7 +43,6 @@ import {
} from "../admin-console/models/response/provider/provider-user.response";
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
import { TokenService } from "../auth/abstractions/token.service";
import { AuthRequest } from "../auth/models/request/auth.request";
import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request";
import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request";
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
@@ -56,17 +55,13 @@ import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-
import { WebAuthnLoginTokenRequest } from "../auth/models/request/identity-token/webauthn-login-token.request";
import { KeyConnectorUserKeyRequest } from "../auth/models/request/key-connector-user-key.request";
import { PasswordHintRequest } from "../auth/models/request/password-hint.request";
import { PasswordRequest } from "../auth/models/request/password.request";
import { PasswordlessAuthRequest } from "../auth/models/request/passwordless-auth.request";
import { SecretVerificationRequest } from "../auth/models/request/secret-verification.request";
import { SetKeyConnectorKeyRequest } from "../auth/models/request/set-key-connector-key.request";
import { SetPasswordRequest } from "../auth/models/request/set-password.request";
import { TwoFactorEmailRequest } from "../auth/models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../auth/models/request/two-factor-provider.request";
import { TwoFactorRecoveryRequest } from "../auth/models/request/two-factor-recovery.request";
import { UpdateProfileRequest } from "../auth/models/request/update-profile.request";
import { UpdateTdeOffboardingPasswordRequest } from "../auth/models/request/update-tde-offboarding-password.request";
import { UpdateTempPasswordRequest } from "../auth/models/request/update-temp-password.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../auth/models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../auth/models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../auth/models/request/update-two-factor-email.request";
@@ -279,22 +274,6 @@ export class ApiService implements ApiServiceAbstraction {
}
// TODO: PM-3519: Create and move to AuthRequest Api service
// TODO: PM-9724: Remove legacy auth request methods when we remove legacy LoginViaAuthRequestV1Components
async postAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/", request, false, true);
return new AuthRequestResponse(r);
}
async postAdminAuthRequest(request: AuthRequest): Promise<AuthRequestResponse> {
const r = await this.send("POST", "/auth-requests/admin-request", request, true, true);
return new AuthRequestResponse(r);
}
async getAuthResponse(id: string, accessCode: string): Promise<AuthRequestResponse> {
const path = `/auth-requests/${id}/response?code=${accessCode}`;
const r = await this.send("GET", path, null, false, true);
return new AuthRequestResponse(r);
}
async getAuthRequest(id: string): Promise<AuthRequestResponse> {
const path = `/auth-requests/${id}`;
const r = await this.send("GET", path, null, true, true);
@@ -374,14 +353,6 @@ export class ApiService implements ApiServiceAbstraction {
return this.send("POST", "/accounts/email", request, true, false);
}
postPassword(request: PasswordRequest): Promise<any> {
return this.send("POST", "/accounts/password", request, true, false);
}
setPassword(request: SetPasswordRequest): Promise<any> {
return this.send("POST", "/accounts/set-password", request, true, false);
}
postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any> {
return this.send("POST", "/accounts/set-key-connector-key", request, true, false);
}
@@ -479,14 +450,6 @@ export class ApiService implements ApiServiceAbstraction {
return new ApiKeyResponse(r);
}
putUpdateTempPassword(request: UpdateTempPasswordRequest): Promise<any> {
return this.send("PUT", "/accounts/update-temp-password", request, true, false);
}
putUpdateTdeOffboardingPassword(request: UpdateTdeOffboardingPasswordRequest): Promise<void> {
return this.send("PUT", "/accounts/update-tde-offboarding-password", request, true, false);
}
postConvertToKeyConnector(): Promise<void> {
return this.send("POST", "/accounts/convert-to-key-connector", null, true, false);
}

View File

@@ -54,14 +54,7 @@ export class SendAccess extends Domain {
async decrypt(key: SymmetricCryptoKey): Promise<SendAccessView> {
const model = new SendAccessView(this);
await this.decryptObj(
model,
{
name: null,
},
null,
key,
);
await this.decryptObj<SendAccess, SendAccessView>(this, model, ["name"], null, key);
switch (this.type) {
case SendType.File:

View File

@@ -34,15 +34,13 @@ export class SendFile extends Domain {
}
async decrypt(key: SymmetricCryptoKey): Promise<SendFileView> {
const view = await this.decryptObj(
return await this.decryptObj<SendFile, SendFileView>(
this,
new SendFileView(this),
{
fileName: null,
},
["fileName"],
null,
key,
);
return view;
}
static fromJSON(obj: Jsonify<SendFile>) {

View File

@@ -30,11 +30,10 @@ export class SendText extends Domain {
}
decrypt(key: SymmetricCryptoKey): Promise<SendTextView> {
return this.decryptObj(
return this.decryptObj<SendText, SendTextView>(
this,
new SendTextView(this),
{
text: null,
},
["text"],
null,
key,
);

View File

@@ -87,15 +87,7 @@ export class Send extends Domain {
// TODO: error?
}
await this.decryptObj(
model,
{
name: null,
notes: null,
},
null,
model.cryptoKey,
);
await this.decryptObj<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);
switch (this.type) {
case SendType.File:

View File

@@ -11,3 +11,4 @@ export type CipherId = Opaque<string, "CipherId">;
export type SendId = Opaque<string, "SendId">;
export type IndexedEntityId = Opaque<string, "IndexedEntityId">;
export type SecurityTaskId = Opaque<string, "SecurityTaskId">;
export type NotificationId = Opaque<string, "NotificationId">;

View File

@@ -43,11 +43,10 @@ export class Attachment extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<AttachmentView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Attachment, AttachmentView>(
this,
new AttachmentView(this),
{
fileName: null,
},
["fileName"],
orgId,
encKey,
"DomainType: Attachment; " + context,

View File

@@ -42,16 +42,10 @@ export class Card extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<CardView> {
return this.decryptObj(
return this.decryptObj<Card, CardView>(
this,
new CardView(),
{
cardholderName: null,
brand: null,
number: null,
expMonth: null,
expYear: null,
code: null,
},
["cardholderName", "brand", "number", "expMonth", "expYear", "code"],
orgId,
encKey,
"DomainType: Card; " + context,

View File

@@ -154,12 +154,10 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
bypassValidation = false;
}
await this.decryptObj(
await this.decryptObj<Cipher, CipherView>(
this,
model,
{
name: null,
notes: null,
},
["name", "notes"],
this.organizationId,
encKey,
);

View File

@@ -52,41 +52,38 @@ export class Fido2Credential extends Domain {
}
async decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<Fido2CredentialView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Fido2Credential, Fido2CredentialView>(
this,
new Fido2CredentialView(),
{
credentialId: null,
keyType: null,
keyAlgorithm: null,
keyCurve: null,
keyValue: null,
rpId: null,
userHandle: null,
userName: null,
rpName: null,
userDisplayName: null,
discoverable: null,
},
[
"credentialId",
"keyType",
"keyAlgorithm",
"keyCurve",
"keyValue",
"rpId",
"userHandle",
"userName",
"rpName",
"userDisplayName",
],
orgId,
encKey,
);
const { counter } = await this.decryptObj(
{ counter: "" },
const { counter } = await this.decryptObj<
Fido2Credential,
{
counter: null,
},
orgId,
encKey,
);
counter: string;
}
>(this, { counter: "" }, ["counter"], orgId, encKey);
// Counter will end up as NaN if this fails
view.counter = parseInt(counter);
const { discoverable } = await this.decryptObj(
const { discoverable } = await this.decryptObj<Fido2Credential, { discoverable: string }>(
this,
{ discoverable: "" },
{
discoverable: null,
},
["discoverable"],
orgId,
encKey,
);

View File

@@ -35,12 +35,10 @@ export class Field extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<FieldView> {
return this.decryptObj(
return this.decryptObj<Field, FieldView>(
this,
new FieldView(this),
{
name: null,
value: null,
},
["name", "value"],
orgId,
encKey,
);

View File

@@ -40,13 +40,7 @@ export class Folder extends Domain {
}
decrypt(): Promise<FolderView> {
return this.decryptObj(
new FolderView(this),
{
name: null,
},
null,
);
return this.decryptObj<Folder, FolderView>(this, new FolderView(this), ["name"], null);
}
async decryptWithKey(

View File

@@ -66,28 +66,29 @@ export class Identity extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<IdentityView> {
return this.decryptObj(
return this.decryptObj<Identity, IdentityView>(
this,
new IdentityView(),
{
title: null,
firstName: null,
middleName: null,
lastName: null,
address1: null,
address2: null,
address3: null,
city: null,
state: null,
postalCode: null,
country: null,
company: null,
email: null,
phone: null,
ssn: null,
username: null,
passportNumber: null,
licenseNumber: null,
},
[
"title",
"firstName",
"middleName",
"lastName",
"address1",
"address2",
"address3",
"city",
"state",
"postalCode",
"country",
"company",
"email",
"phone",
"ssn",
"username",
"passportNumber",
"licenseNumber",
],
orgId,
encKey,
"DomainType: Identity; " + context,

View File

@@ -38,11 +38,10 @@ export class LoginUri extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginUriView> {
return this.decryptObj(
return this.decryptObj<LoginUri, LoginUriView>(
this,
new LoginUriView(this),
{
uri: null,
},
["uri"],
orgId,
encKey,
context,

View File

@@ -58,13 +58,10 @@ export class Login extends Domain {
context: string = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<LoginView> {
const view = await this.decryptObj(
const view = await this.decryptObj<Login, LoginView>(
this,
new LoginView(this),
{
username: null,
password: null,
totp: null,
},
["username", "password", "totp"],
orgId,
encKey,
`DomainType: Login; ${context}`,

View File

@@ -25,11 +25,10 @@ export class Password extends Domain {
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<PasswordHistoryView> {
return this.decryptObj(
return this.decryptObj<Password, PasswordHistoryView>(
this,
new PasswordHistoryView(this),
{
password: null,
},
["password"],
orgId,
encKey,
"DomainType: PasswordHistory",

View File

@@ -36,13 +36,10 @@ export class SshKey extends Domain {
context = "No Cipher Context",
encKey?: SymmetricCryptoKey,
): Promise<SshKeyView> {
return this.decryptObj(
return this.decryptObj<SshKey, SshKeyView>(
this,
new SshKeyView(),
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
["privateKey", "publicKey", "keyFingerprint"],
orgId,
encKey,
"DomainType: SshKey; " + context,

View File

@@ -6,12 +6,8 @@
"@bitwarden/auth/common": ["../auth/src/common"],
// TODO: Remove once circular dependencies in admin-console, auth and key-management are resolved
"@bitwarden/common/*": ["../common/src/*"],
// TODO: Remove once billing stops depending on components
"@bitwarden/components": ["../components/src"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
// TODO: Remove once billing stops depending on components
"@bitwarden/ui-common": ["../ui/common/src"]
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"]
}
},
"include": ["src", "spec", "./custom-matchers.d.ts", "../key-management/src/index.ts"],

View File

@@ -12,7 +12,7 @@
</tr>
</thead>
<tbody>
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy" bitRow>
<tr *cdkVirtualFor="let r of rows$; trackBy: trackBy; templateCacheSize: 0" bitRow>
<ng-container *ngTemplateOutlet="rowDef.template; context: { $implicit: r }"></ng-container>
</tr>
</tbody>

View File

@@ -7,7 +7,7 @@ const sharedConfig = require("../shared/jest.config.ts");
/** @type {import('jest').Config} */
module.exports = {
...sharedConfig,
preset: "ts-jest",
preset: "jest-preset-angular",
testEnvironment: "jsdom",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",

View File

@@ -37,6 +37,7 @@ import { EncryptService } from "@bitwarden/common/key-management/crypto/abstract
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -96,6 +97,7 @@ const safeProviders: SafeProvider[] = [
EncryptService,
PinServiceAbstraction,
AccountService,
SdkService,
],
}),
];

View File

@@ -1,16 +1,19 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { KeyService } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer";
import { Importer } from "../importers/importer";
@@ -30,6 +33,7 @@ describe("ImportService", () => {
let encryptService: MockProxy<EncryptService>;
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
let sdkService: MockProxy<SdkService>;
beforeEach(() => {
cipherService = mock<CipherService>();
@@ -40,6 +44,9 @@ describe("ImportService", () => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
pinService = mock<PinServiceAbstraction>();
const mockClient = mock<BitwardenClient>();
sdkService = mock<SdkService>();
sdkService.client$ = of(mockClient, mockClient, mockClient);
importService = new ImportService(
cipherService,
@@ -51,6 +58,7 @@ describe("ImportService", () => {
encryptService,
pinService,
accountService,
sdkService,
);
});

View File

@@ -15,6 +15,7 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque
import { KvpRequest } from "@bitwarden/common/models/request/kvp.request";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -114,6 +115,7 @@ export class ImportService implements ImportServiceAbstraction {
private encryptService: EncryptService,
private pinService: PinServiceAbstraction,
private accountService: AccountService,
private sdkService: SdkService,
) {}
getImportOptions(): ImportOption[] {

View File

@@ -34,7 +34,7 @@ export class DefaultSendFormConfigService implements SendFormConfigService {
return {
mode,
sendType: sendType,
sendType: send?.type ?? sendType ?? SendType.Text,
areSendsAllowed,
originalSend: send,
};

View File

@@ -24,6 +24,7 @@ import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
@@ -39,6 +40,8 @@ import {
// eslint-disable-next-line no-restricted-imports
import { PreloadedEnglishI18nModule } from "@bitwarden/web-vault/src/app/core/tests";
import { SshImportPromptService } from "../services/ssh-import-prompt.service";
import { CipherFormService } from "./abstractions/cipher-form.service";
import { TotpCaptureService } from "./abstractions/totp-capture.service";
import { CipherFormModule } from "./cipher-form.module";
@@ -146,6 +149,12 @@ export default {
enabled$: new BehaviorSubject(true),
},
},
{
provide: SshImportPromptService,
useValue: {
importSshKeyFromClipboard: () => Promise.resolve(new SshKeyData()),
},
},
{
provide: CipherFormGenerationService,
useValue: {

View File

@@ -5,12 +5,15 @@
</h2>
</bit-section-header>
<bit-card>
<bit-card cdkDropList (cdkDropListDropped)="onUriItemDrop($event)">
<ng-container formArrayName="uris">
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
cdkDrag
[formControlName]="i"
(remove)="removeUri(i)"
(onKeydown)="onUriItemKeydown($event, i)"
[canReorder]="uriControls.length > 1"
[canRemove]="uriControls.length > 1"
[defaultMatchDetection]="defaultMatchDetection$ | async"
[index]="i"

View File

@@ -1,4 +1,5 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
@@ -16,6 +17,14 @@ import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "./autofill-options.component";
jest.mock("@angular/cdk/drag-drop", () => {
const actual = jest.requireActual("@angular/cdk/drag-drop");
return {
...actual,
moveItemInArray: jest.fn(actual.moveItemInArray),
};
});
describe("AutofillOptionsComponent", () => {
let component: AutofillOptionsComponent;
let fixture: ComponentFixture<AutofillOptionsComponent>;
@@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => {
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
});
describe("Drag & Drop Functionality", () => {
beforeEach(() => {
// Prevent autoadding an empty URI by setting a nonnull initial value.
// This overrides the call to initNewCipher.
// Now clear any existing URIs (including the autoadded one)
component.autofillOptionsForm.controls.uris.clear();
// Add exactly three URIs that we want to test reordering on.
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
});
it("should reorder URI inputs on drop event", () => {
// Simulate a drop event that moves the first URI (index 0) to the last position (index 2).
const dropEvent: CdkDragDrop<HTMLDivElement> = {
previousIndex: 0,
currentIndex: 2,
container: null,
previousContainer: null,
isPointerOverContainer: true,
item: null,
distance: { x: 0, y: 0 },
} as any;
component.onUriItemDrop(dropEvent);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
0,
2,
);
});
it("should reorder URI input via keyboard ArrowUp", async () => {
// Clear and add exactly two URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowUp on the second URI (index 1)
const keyEvent = {
key: "ArrowUp",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
// Force requestAnimationFrame to run synchronously
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
0,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldUp websiteUri 1 2",
"assertive",
);
});
it("should reorder URI input via keyboard ArrowDown", async () => {
// Clear and add exactly three URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowDown on the second URI (index 1)
const keyEvent = {
key: "ArrowDown",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
2,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldDown websiteUri 3 3",
"assertive",
);
});
});
});

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -41,6 +42,7 @@ interface UriField {
templateUrl: "./autofill-options.component.html",
standalone: true,
imports: [
DragDropModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
@@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit {
removeUri(i: number) {
this.autofillOptionsForm.controls.uris.removeAt(i);
}
/** Create a new list of LoginUriViews from the form objects and update the cipher */
private updateUriFields() {
this.cipherFormContainer.patchCipher((cipher) => {
cipher.login.uris = this.uriControls.map(
(control) =>
Object.assign(new LoginUriView(), {
uri: control.value.uri,
matchDetection: control.value.matchDetection ?? null,
}) as LoginUriView,
);
return cipher;
});
}
/** Reorder the controls to match the new order after a "drop" event */
onUriItemDrop(event: CdkDragDrop<HTMLDivElement>) {
moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex);
this.updateUriFields();
}
/** Handles a uri item keyboard up or down event */
async onUriItemKeydown(event: KeyboardEvent, index: number) {
if (event.key === "ArrowUp" && index !== 0) {
await this.reorderUriItems(event, index, "Up");
}
if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) {
await this.reorderUriItems(event, index, "Down");
}
}
/** Reorders the uri items from a keyboard up or down event */
async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") {
const currentIndex = previousIndex + (direction === "Up" ? -1 : 1);
event.preventDefault();
await this.liveAnnouncer.announce(
this.i18nService.t(
`reorderField${direction}`,
this.i18nService.t("websiteUri"),
currentIndex + 1,
this.uriControls.length,
),
"assertive",
);
moveItemInArray(this.uriControls, previousIndex, currentIndex);
this.updateUriFields();
// Refocus the button after the reorder
// Angular re-renders the list when moving an item up which causes the focus to be lost
// Wait for the next tick to ensure the button is rendered before focusing
requestAnimationFrame(() => {
(event.target as HTMLButtonElement).focus();
});
}
}

View File

@@ -1,35 +1,50 @@
<ng-container [formGroup]="uriForm">
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
bitIconButton="bwi-cog"
bitSuffix
[appA11yTitle]="toggleTitle"
(click)="toggleMatchDetection()"
data-testid="toggle-match-detection-button"
></button>
<button
type="button"
bitIconButton="bwi-minus-circle"
buttonType="danger"
bitSuffix
[appA11yTitle]="'deleteWebsite' | i18n"
*ngIf="canRemove"
(click)="removeUri()"
data-testid="remove-uri-button"
></button>
</bit-form-field>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
></bit-option>
</bit-select>
</bit-form-field>
<div class="tw-mb-4 pt-1">
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
type="button"
bitIconButton="bwi-cog"
bitSuffix
[appA11yTitle]="toggleTitle"
(click)="toggleMatchDetection()"
data-testid="toggle-match-detection-button"
></button>
<button
type="button"
bitIconButton="bwi-minus-circle"
buttonType="danger"
bitSuffix
[appA11yTitle]="'deleteWebsite' | i18n"
*ngIf="canRemove"
(click)="removeUri()"
data-testid="remove-uri-button"
></button>
</bit-form-field>
<div class="tw-flex tw-items-center tw-ml-1.5">
<button
type="button"
bitIconButton="bwi-hamburger"
class="!tw-py-0 !tw-px-1"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: uriLabel"
(keydown)="handleKeydown($event)"
data-testid="reorder-toggle-button"
*ngIf="canReorder"
></button>
</div>
</div>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
<bit-option
*ngFor="let o of uriMatchOptions"
[label]="o.label"
[value]="o.value"
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DragDropModule } from "@angular/cdk/drag-drop";
import { NgForOf, NgIf } from "@angular/common";
import {
Component,
@@ -43,6 +44,7 @@ import {
},
],
imports: [
DragDropModule,
FormFieldModule,
ReactiveFormsModule,
IconButtonModule,
@@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor {
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
];
/**
* Whether the option can be reordered. If false, the reorder button will be hidden.
*/
@Input({ required: true })
canReorder: boolean;
/**
* Whether the URI can be removed from the form. If false, the remove button will be hidden.
*/
@@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor {
*/
@Input({ required: true }) index: number;
@Output()
onKeydown = new EventEmitter<KeyboardEvent>();
/**
* Emits when the remove button is clicked and URI should be removed from the form.
*/
@@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor {
private onChange: any = () => {};
private onTouched: any = () => {};
protected handleKeydown(event: KeyboardEvent) {
this.onKeydown.emit(event);
}
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,

View File

@@ -87,7 +87,7 @@
(click)="openAddEditCustomFieldDialog({ index: i, label: field.value.name })"
[appA11yTitle]="'editFieldLabel' | i18n: field.value.name"
bitIconButton="bwi-pencil-square"
class="tw-self-end"
class="tw-self-center tw-mt-2"
data-testid="edit-custom-field-button"
*ngIf="!isPartialEdit"
></button>
@@ -95,7 +95,7 @@
<button
type="button"
bitIconButton="bwi-hamburger"
class="tw-self-end"
class="tw-self-center tw-mt-2"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: field.value.name"
(keydown)="handleKeyDown($event, field.value.name, i)"

View File

@@ -15,6 +15,14 @@
data-testid="toggle-privateKey-visibility"
bitPasswordInputToggle
></button>
<button
type="button"
bitIconButton="bwi-paste"
bitSuffix
data-testid="import-privateKey"
*ngIf="showImport"
(click)="importSshKeyFromClipboard()"
></button>
</bit-form-field>
<bit-form-field>

View File

@@ -7,7 +7,8 @@ import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
@@ -22,6 +23,7 @@ import {
} from "@bitwarden/components";
import { generate_ssh_key } from "@bitwarden/sdk-internal";
import { SshImportPromptService } from "../../../services/ssh-import-prompt.service";
import { CipherFormContainer } from "../../cipher-form-container";
@Component({
@@ -60,11 +62,14 @@ export class SshKeySectionComponent implements OnInit {
keyFingerprint: [""],
});
showImport = false;
constructor(
private cipherFormContainer: CipherFormContainer,
private formBuilder: FormBuilder,
private i18nService: I18nService,
private sdkService: SdkService,
private sshImportPromptService: SshImportPromptService,
private platformUtilsService: PlatformUtilsService,
) {
this.cipherFormContainer.registerChildForm("sshKeyDetails", this.sshKeyForm);
this.sshKeyForm.valueChanges.pipe(takeUntilDestroyed()).subscribe((value) => {
@@ -87,6 +92,11 @@ export class SshKeySectionComponent implements OnInit {
}
this.sshKeyForm.disable();
// Web does not support clipboard access
if (this.platformUtilsService.getClientType() !== ClientType.Web) {
this.showImport = true;
}
}
/** Set form initial form values from the current cipher */
@@ -100,6 +110,17 @@ export class SshKeySectionComponent implements OnInit {
});
}
async importSshKeyFromClipboard() {
const key = await this.sshImportPromptService.importSshKeyFromClipboard();
if (key != null) {
this.sshKeyForm.setValue({
privateKey: key.privateKey,
publicKey: key.publicKey,
keyFingerprint: key.keyFingerprint,
});
}
}
private async generateSshKey() {
await firstValueFrom(this.sdkService.client$);
const sshKey = generate_ssh_key("Ed25519");

View File

@@ -171,6 +171,10 @@ export class CipherViewComponent implements OnChanges, OnDestroy {
}
async checkPendingChangePasswordTasks(userId: UserId): Promise<void> {
if (!(await firstValueFrom(this.isSecurityTasksEnabled$))) {
return;
}
const tasks = await firstValueFrom(this.defaultTaskService.pendingTasks$(userId));
this.hadPendingChangePasswordTask = tasks?.some((task) => {

View File

@@ -3,12 +3,12 @@
<h2 bitTypography="h6">{{ "itemHistory" | i18n }}</h2>
</bit-section-header>
<bit-card>
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-none">
<p class="tw-mb-1 tw-text-xs tw-text-muted tw-select-all">
<span class="tw-font-bold">{{ "lastEdited" | i18n }}:</span>
{{ cipher.revisionDate | date: "medium" }}
</p>
<p
class="tw-text-xs tw-text-muted tw-select-none"
class="tw-text-xs tw-text-muted tw-select-all"
[ngClass]="{
'tw-mb-1 ': cipher.hasPasswordHistory,
'tw-mb-0': !cipher.hasPasswordHistory,
@@ -19,7 +19,7 @@
</p>
<p
*ngIf="cipher.passwordRevisionDisplayDate"
class="tw-text-xs tw-text-muted tw-select-none"
class="tw-text-xs tw-text-muted tw-select-all"
[ngClass]="{ 'tw-mb-3': cipher.hasPasswordHistory }"
>
<span class="tw-font-bold">{{ "datePasswordUpdated" | i18n }}:</span>

View File

@@ -25,8 +25,11 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
export * from "./components/carousel";
export * as VaultIcons from "./icons";
export * from "./tasks";
export * from "./notifications";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";
export * from "./abstractions/change-login-password.service";
export * from "./services/default-change-login-password.service";

View File

@@ -0,0 +1,49 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { NotificationView } from "../models";
/**
* A service for retrieving and managing notifications for end users.
*/
export abstract class EndUserNotificationService {
/**
* Observable of all notifications for the given user.
* @param userId
*/
abstract notifications$(userId: UserId): Observable<NotificationView[]>;
/**
* Observable of all unread notifications for the given user.
* @param userId
*/
abstract unreadNotifications$(userId: UserId): Observable<NotificationView[]>;
/**
* Mark a notification as read.
* @param notificationId
* @param userId
*/
abstract markAsRead(notificationId: any, userId: UserId): Promise<void>;
/**
* Mark a notification as deleted.
* @param notificationId
* @param userId
*/
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
/**
* Create/update a notification in the state for the user specified within the notification.
* @remarks This method should only be called when a notification payload is received from the web socket.
* @param notification
*/
abstract upsert(notification: Notification): Promise<void>;
/**
* Clear all notifications from state for the given user.
* @param userId
*/
abstract clearState(userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,2 @@
export * from "./abstractions/end-user-notification.service";
export * from "./services/default-end-user-notification.service";

View File

@@ -0,0 +1,3 @@
export * from "./notification-view";
export * from "./notification-view.data";
export * from "./notification-view.response";

View File

@@ -0,0 +1,37 @@
import { Jsonify } from "type-fest";
import { NotificationId } from "@bitwarden/common/types/guid";
import { NotificationViewResponse } from "./notification-view.response";
export class NotificationViewData {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
readDate: Date | null;
deletedDate: Date | null;
constructor(response: NotificationViewResponse) {
this.id = response.id;
this.priority = response.priority;
this.title = response.title;
this.body = response.body;
this.date = response.date;
this.readDate = response.readDate;
this.deletedDate = response.deletedDate;
}
static fromJSON(obj: Jsonify<NotificationViewData>) {
return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, {
id: obj.id,
priority: obj.priority,
title: obj.title,
body: obj.body,
date: new Date(obj.date),
readDate: obj.readDate ? new Date(obj.readDate) : null,
deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null,
});
}
}

View File

@@ -0,0 +1,23 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { NotificationId } from "@bitwarden/common/types/guid";
export class NotificationViewResponse extends BaseResponse {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
readDate: Date;
deletedDate: Date;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.priority = this.getResponseProperty("Priority");
this.title = this.getResponseProperty("Title");
this.body = this.getResponseProperty("Body");
this.date = this.getResponseProperty("Date");
this.readDate = this.getResponseProperty("ReadDate");
this.deletedDate = this.getResponseProperty("DeletedDate");
}
}

View File

@@ -0,0 +1,21 @@
import { NotificationId } from "@bitwarden/common/types/guid";
export class NotificationView {
id: NotificationId;
priority: number;
title: string;
body: string;
date: Date;
readDate: Date | null;
deletedDate: Date | null;
constructor(obj: any) {
this.id = obj.id;
this.priority = obj.priority;
this.title = obj.title;
this.body = obj.body;
this.date = obj.date;
this.readDate = obj.readDate;
this.deletedDate = obj.deletedDate;
}
}

View File

@@ -0,0 +1,193 @@
import { TestBed } from "@angular/core/testing";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { NotificationId, UserId } from "@bitwarden/common/types/guid";
import { DefaultEndUserNotificationService } from "@bitwarden/vault";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
import { NotificationViewResponse } from "../models";
import { NOTIFICATIONS } from "../state/end-user-notification.state";
describe("End User Notification Center Service", () => {
let fakeStateProvider: FakeStateProvider;
const mockApiSend = jest.fn();
let testBed: TestBed;
beforeEach(async () => {
mockApiSend.mockClear();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
testBed = TestBed.configureTestingModule({
imports: [],
providers: [
DefaultEndUserNotificationService,
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: ApiService,
useValue: {
send: mockApiSend,
},
},
],
});
});
describe("notifications$", () => {
it("should return notifications from state when not null", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
} as NotificationViewResponse,
]);
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
const result = await firstValueFrom(notifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiSend).not.toHaveBeenCalled();
});
it("should return notifications API when state is null", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any);
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
const result = await firstValueFrom(notifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
});
it("should share the same observable for the same user", async () => {
const { notifications$ } = testBed.inject(DefaultEndUserNotificationService);
const first = notifications$("user-id" as UserId);
const second = notifications$("user-id" as UserId);
expect(first).toBe(second);
});
});
describe("unreadNotifications$", () => {
it("should return unread notifications from state when read value is null", async () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
readDate: null as any,
} as NotificationViewResponse,
]);
const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService);
const result = await firstValueFrom(unreadNotifications$("user-id" as UserId));
expect(result.length).toBe(1);
expect(mockApiSend).not.toHaveBeenCalled();
});
});
describe("getNotifications", () => {
it("should call getNotifications returning notifications from API", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
const service = testBed.inject(DefaultEndUserNotificationService);
await service.getNotifications("user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true);
});
});
it("should update local state when notifications are updated", async () => {
mockApiSend.mockResolvedValue({
data: [
{
id: "notification-id",
},
] as NotificationViewResponse[],
});
const mock = fakeStateProvider.singleUser.mockFor(
"user-id" as UserId,
NOTIFICATIONS,
null as any,
);
const service = testBed.inject(DefaultEndUserNotificationService);
await service.getNotifications("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([
expect.objectContaining({
id: "notification-id" as NotificationId,
} as NotificationViewResponse),
]);
});
describe("clear", () => {
it("should clear the local notification state for the user", async () => {
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [
{
id: "notification-id" as NotificationId,
} as NotificationViewResponse,
]);
const service = testBed.inject(DefaultEndUserNotificationService);
await service.clearState("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([]);
});
});
describe("markAsDeleted", () => {
it("should send an API request to mark the notification as deleted", async () => {
const service = testBed.inject(DefaultEndUserNotificationService);
await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith(
"DELETE",
"/notifications/notification-id/delete",
null,
true,
false,
);
});
});
describe("markAsRead", () => {
it("should send an API request to mark the notification as read", async () => {
const service = testBed.inject(DefaultEndUserNotificationService);
await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith(
"PATCH",
"/notifications/notification-id/read",
null,
true,
false,
);
});
});
});

View File

@@ -0,0 +1,104 @@
import { Injectable } from "@angular/core";
import { map, Observable, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
import { NOTIFICATIONS } from "../state/end-user-notification.state";
/**
* A service for retrieving and managing notifications for end users.
*/
@Injectable()
export class DefaultEndUserNotificationService implements EndUserNotificationService {
constructor(
private stateProvider: StateProvider,
private apiService: ApiService,
) {}
notifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
return this.notificationState(userId).state$.pipe(
switchMap(async (notifications) => {
if (notifications == null) {
await this.fetchNotificationsFromApi(userId);
}
return notifications;
}),
filterOutNullish(),
map((notifications) =>
notifications.map((notification) => new NotificationView(notification)),
),
);
});
unreadNotifications$ = perUserCache$((userId: UserId): Observable<NotificationView[]> => {
return this.notifications$(userId).pipe(
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
);
});
async markAsRead(notificationId: any, userId: UserId): Promise<void> {
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
await this.getNotifications(userId);
}
async markAsDeleted(notificationId: any, userId: UserId): Promise<void> {
await this.apiService.send(
"DELETE",
`/notifications/${notificationId}/delete`,
null,
true,
false,
);
await this.getNotifications(userId);
}
upsert(notification: Notification): any {}
async clearState(userId: UserId): Promise<void> {
await this.updateNotificationState(userId, []);
}
async getNotifications(userId: UserId) {
await this.fetchNotificationsFromApi(userId);
}
/**
* Fetches the notifications from the API and updates the local state
* @param userId
* @private
*/
private async fetchNotificationsFromApi(userId: UserId): Promise<void> {
const res = await this.apiService.send("GET", "/notifications", null, true, true);
const response = new ListResponse(res, NotificationViewResponse);
const notificationData = response.data.map((n) => new NotificationView(n));
await this.updateNotificationState(userId, notificationData);
}
/**
* Updates the local state with notifications and returns the updated state
* @param userId
* @param notifications
* @private
*/
private updateNotificationState(
userId: UserId,
notifications: NotificationViewData[],
): Promise<NotificationViewData[] | null> {
return this.notificationState(userId).update(() => notifications);
}
/**
* Returns the local state for notifications
* @param userId
* @private
*/
private notificationState(userId: UserId) {
return this.stateProvider.getUser(userId, NOTIFICATIONS);
}
}

View File

@@ -0,0 +1,15 @@
import { Jsonify } from "type-fest";
import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
import { NotificationViewData } from "../models";
export const NOTIFICATIONS = UserKeyDefinition.array<NotificationViewData>(
NOTIFICATION_DISK,
"notifications",
{
deserializer: (notification: Jsonify<NotificationViewData>) =>
NotificationViewData.fromJSON(notification),
clearOn: ["logout", "lock"],
},
);

View File

@@ -0,0 +1,109 @@
import { Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { DialogService, ToastService } from "@bitwarden/components";
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer-ui";
import { import_ssh_key, SshKeyImportError, SshKeyView } from "@bitwarden/sdk-internal";
import { SshImportPromptService } from "./ssh-import-prompt.service";
/**
* Used to import ssh keys and prompt for their password.
*/
@Injectable()
export class DefaultSshImportPromptService implements SshImportPromptService {
constructor(
private dialogService: DialogService,
private toastService: ToastService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
) {}
async importSshKeyFromClipboard(): Promise<SshKeyData | null> {
const key = await this.platformUtilsService.readFromClipboard();
let isPasswordProtectedSshKey = false;
let parsedKey: SshKeyView | null = null;
try {
parsedKey = import_ssh_key(key);
} catch (e) {
const error = e as SshKeyImportError;
if (error.variant === "PasswordRequired" || error.variant === "WrongPassword") {
isPasswordProtectedSshKey = true;
} else {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
});
return null;
}
}
if (isPasswordProtectedSshKey) {
for (;;) {
const password = await this.getSshKeyPassword();
if (password === "" || password == null) {
return null;
}
try {
parsedKey = import_ssh_key(key, password);
break;
} catch (e) {
const error = e as SshKeyImportError;
if (error.variant !== "WrongPassword") {
this.toastService.showToast({
variant: "error",
title: "",
message: this.i18nService.t(this.sshImportErrorVariantToI18nKey(error.variant)),
});
return null;
}
}
}
}
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("sshKeyImported"),
});
return new SshKeyData(
new SshKeyApi({
privateKey: parsedKey!.privateKey,
publicKey: parsedKey!.publicKey,
keyFingerprint: parsedKey!.fingerprint,
}),
);
}
private sshImportErrorVariantToI18nKey(variant: string): string {
switch (variant) {
case "ParsingError":
return "invalidSshKey";
case "UnsupportedKeyType":
return "sshKeyTypeUnsupported";
case "PasswordRequired":
case "WrongPassword":
return "sshKeyWrongPassword";
default:
return "errorOccurred";
}
}
private async getSshKeyPassword(): Promise<string | undefined> {
const dialog = this.dialogService.open<string>(SshKeyPasswordPromptComponent, {
ariaModal: true,
});
return await firstValueFrom(dialog.closed);
}
}

View File

@@ -0,0 +1,111 @@
import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SshKeyApi } from "@bitwarden/common/vault/models/api/ssh-key.api";
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
import { DialogService, ToastService } from "@bitwarden/components";
import * as sdkInternal from "@bitwarden/sdk-internal";
import { DefaultSshImportPromptService } from "./default-ssh-import-prompt.service";
jest.mock("@bitwarden/sdk-internal");
const exampleSshKey = {
privateKey: "private_key",
publicKey: "public_key",
fingerprint: "key_fingerprint",
} as sdkInternal.SshKeyView;
const exampleSshKeyData = new SshKeyData(
new SshKeyApi({
publicKey: exampleSshKey.publicKey,
privateKey: exampleSshKey.privateKey,
keyFingerprint: exampleSshKey.fingerprint,
}),
);
describe("SshImportPromptService", () => {
let sshImportPromptService: DefaultSshImportPromptService;
let dialogService: MockProxy<DialogService>;
let toastService: MockProxy<ToastService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let i18nService: MockProxy<I18nService>;
beforeEach(() => {
dialogService = mock<DialogService>();
toastService = mock<ToastService>();
platformUtilsService = mock<PlatformUtilsService>();
i18nService = mock<I18nService>();
sshImportPromptService = new DefaultSshImportPromptService(
dialogService,
toastService,
platformUtilsService,
i18nService,
);
jest.clearAllMocks();
});
describe("importSshKeyFromClipboard()", () => {
it("imports unencrypted ssh key", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockReturnValue(exampleSshKey);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
});
it("requests password for encrypted ssh key", async () => {
jest
.spyOn(sdkInternal, "import_ssh_key")
.mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
})
.mockImplementationOnce(() => exampleSshKey);
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(exampleSshKeyData);
expect(dialogService.open).toHaveBeenCalled();
});
it("cancels when no password was provided", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
});
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("") } as any);
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(dialogService.open).toHaveBeenCalled();
});
it("passes through error on no password", async () => {
jest.spyOn(sdkInternal, "import_ssh_key").mockImplementationOnce(() => {
throw { variant: "UnsupportedKeyType" };
});
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
});
it("passes through error with password", async () => {
jest
.spyOn(sdkInternal, "import_ssh_key")
.mockClear()
.mockImplementationOnce(() => {
throw { variant: "PasswordRequired" };
})
.mockImplementationOnce(() => {
throw { variant: "UnsupportedKeyType" };
});
platformUtilsService.readFromClipboard.mockResolvedValue("ssh_key");
dialogService.open.mockReturnValue({ closed: new BehaviorSubject("password") } as any);
expect(await sshImportPromptService.importSshKeyFromClipboard()).toEqual(null);
expect(i18nService.t).toHaveBeenCalledWith("sshKeyTypeUnsupported");
});
});
});

View File

@@ -0,0 +1,5 @@
import { SshKeyData } from "@bitwarden/common/vault/models/data/ssh-key.data";
export abstract class SshImportPromptService {
abstract importSshKeyFromClipboard: () => Promise<SshKeyData | null>;
}

View File

@@ -8,11 +8,13 @@
"@bitwarden/auth/common": ["../auth/src/common"],
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/components": ["../components/src"],
"@bitwarden/importer-ui": ["../importer/src/components"],
"@bitwarden/generator-components": ["../tools/generator/components/src"],
"@bitwarden/generator-core": ["../tools/generator/core/src"],
"@bitwarden/generator-history": ["../tools/generator/extensions/history/src"],
"@bitwarden/generator-legacy": ["../tools/generator/extensions/legacy/src"],
"@bitwarden/generator-navigation": ["../tools/generator/extensions/navigation/src"],
"@bitwarden/vault-export-core": ["../tools/export/vault-export/vault-export-core/src"],
"@bitwarden/key-management": ["../key-management/src"],
"@bitwarden/platform": ["../platform/src"],
"@bitwarden/ui-common": ["../ui/common/src"],