1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

resolve merge conflicts

This commit is contained in:
William Martin
2024-11-13 13:00:33 -05:00
715 changed files with 25670 additions and 10211 deletions

View File

@@ -275,4 +275,11 @@ export abstract class OrganizationUserApiService {
organizationId: string,
ids: string[],
): Promise<ListResponse<OrganizationUserBulkResponse>>;
/**
* Remove an organization user's access to the organization and delete their account data
* @param organizationId - Identifier for the organization the user belongs to
* @param id - Organization user identifier
*/
abstract deleteOrganizationUser(organizationId: string, id: string): Promise<void>;
}

View File

@@ -359,4 +359,14 @@ export class DefaultOrganizationUserApiService implements OrganizationUserApiSer
);
return new ListResponse(r, OrganizationUserBulkResponse);
}
deleteOrganizationUser(organizationId: string, id: string): Promise<void> {
return this.apiService.send(
"DELETE",
"/organizations/" + organizationId + "/users/" + id + "/delete-account",
null,
true,
false,
);
}
}

View File

@@ -4,7 +4,7 @@
} as data"
>
<div class="environment-selector-btn">
{{ "loggingInOn" | i18n }}:
{{ "accessing" | i18n }}:
<button
type="button"
(click)="toggle(null)"
@@ -38,18 +38,20 @@
<div class="box-content">
<div
class="environment-selector-dialog"
data-testid="environment-selector-dialog"
[@transformPanel]="'open'"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-modal="true"
>
<ng-container *ngFor="let region of availableRegions">
<ng-container *ngFor="let region of availableRegions; let i = index">
<button
type="button"
class="environment-selector-dialog-item"
(click)="toggle(region.key)"
[attr.aria-pressed]="data.selectedRegion === region ? 'true' : 'false'"
[attr.data-testid]="'environment-selector-dialog-item-' + i"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"
@@ -66,6 +68,7 @@
class="environment-selector-dialog-item"
(click)="toggle(ServerEnvironmentType.SelfHosted)"
[attr.aria-pressed]="data.selectedRegion ? 'false' : 'true'"
data-testid="environment-selector-dialog-item-self-hosted"
>
<i
class="bwi bwi-fw bwi-sm bwi-check"

View File

@@ -1,14 +1,19 @@
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";
import { Router, ActivatedRoute } from "@angular/router";
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,
RegionConfig,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DialogService, ToastService } from "@bitwarden/components";
export const ExtensionDefaultOverlayPosition: ConnectedPosition[] = [
{
@@ -56,7 +61,7 @@ export interface EnvironmentSelectorRouteData {
],
})
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
@Output() onOpenSelfHostedSettings = new EventEmitter();
@Output() onOpenSelfHostedSettings = new EventEmitter<void>();
@Input() overlayPosition: ConnectedPosition[] = [
{
originX: "start",
@@ -79,8 +84,11 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
constructor(
protected environmentService: EnvironmentService,
protected router: Router,
private route: ActivatedRoute,
private dialogService: DialogService,
private configService: ConfigService,
private toastService: ToastService,
private i18nService: I18nService,
) {}
ngOnInit() {
@@ -102,8 +110,25 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
return;
}
/**
* 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.
*/
if (option === Region.SelfHosted) {
this.onOpenSelfHostedSettings.emit();
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();
}
return;
}

View File

@@ -267,6 +267,7 @@ export class LockComponent implements OnInit, OnDestroy {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
response.masterKey,
userId,
);
await this.setUserKeyAndContinue(userKey, userId, true);
}

View File

@@ -5,7 +5,11 @@ import { MockProxy, mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
@@ -30,7 +34,7 @@ describe("AuthGuard", () => {
keyConnectorServiceRequiresAccountConversion,
);
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(

View File

@@ -6,7 +6,11 @@ import { BehaviorSubject, of } from "rxjs";
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
Account,
AccountInfo,
AccountService,
} from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
@@ -56,7 +60,7 @@ describe("lockGuard", () => {
userVerificationService.hasMasterPassword.mockResolvedValue(setupParams.hasMasterPassword);
const accountService: MockProxy<AccountService> = mock<AccountService>();
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
accountService.activeAccount$ = activeAccountSubject;
activeAccountSubject.next(
Object.assign(

View File

@@ -46,7 +46,7 @@ export class ManageTaxInformationComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
await this.onSubmit(this.taxInformation);
await this.onSubmit?.(this.taxInformation);
this.taxInformationUpdated.emit();
};

View File

@@ -1,7 +1,7 @@
import { Inject, Injectable } from "@angular/core";
import { fromEvent, map, merge, Observable, of, Subscription, switchMap } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeTypes, Theme } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { SYSTEM_THEME_OBSERVABLE } from "../../../services/injection-tokens";
@@ -15,7 +15,7 @@ export class AngularThemingService implements AbstractThemingService {
* @param window The window that should be watched for system theme changes.
* @returns An observable that will track the system theme.
*/
static createSystemThemeFromWindow(window: Window): Observable<ThemeType> {
static createSystemThemeFromWindow(window: Window): Observable<Theme> {
return merge(
// This observable should always emit at least once, so go and get the current system theme designation
of(AngularThemingService.getSystemThemeFromWindow(window)),
@@ -23,7 +23,7 @@ export class AngularThemingService implements AbstractThemingService {
fromEvent<MediaQueryListEvent>(
window.matchMedia("(prefers-color-scheme: dark)"),
"change",
).pipe(map((event) => (event.matches ? ThemeType.Dark : ThemeType.Light))),
).pipe(map((event) => (event.matches ? ThemeTypes.Dark : ThemeTypes.Light))),
);
}
@@ -32,15 +32,15 @@ export class AngularThemingService implements AbstractThemingService {
* @param window The window to query for the current theme.
* @returns The active system theme.
*/
static getSystemThemeFromWindow(window: Window): ThemeType {
static getSystemThemeFromWindow(window: Window): Theme {
return window.matchMedia("(prefers-color-scheme: dark)").matches
? ThemeType.Dark
: ThemeType.Light;
? ThemeTypes.Dark
: ThemeTypes.Light;
}
readonly theme$ = this.themeStateService.selectedTheme$.pipe(
switchMap((configuredTheme) => {
if (configuredTheme === ThemeType.System) {
if (configuredTheme === ThemeTypes.System) {
return this.systemTheme$;
}
@@ -51,16 +51,16 @@ export class AngularThemingService implements AbstractThemingService {
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
private systemTheme$: Observable<Theme>,
) {}
applyThemeChangesTo(document: Document): Subscription {
return this.theme$.subscribe((theme) => {
document.documentElement.classList.remove(
"theme_" + ThemeType.Light,
"theme_" + ThemeType.Dark,
"theme_" + ThemeType.Nord,
"theme_" + ThemeType.SolarizedDark,
"theme_" + ThemeTypes.Light,
"theme_" + ThemeTypes.Dark,
"theme_" + ThemeTypes.Nord,
"theme_" + ThemeTypes.SolarizedDark,
);
document.documentElement.classList.add("theme_" + theme);
});

View File

@@ -1,6 +1,6 @@
import { Observable, Subscription } from "rxjs";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Theme } from "@bitwarden/common/platform/enums";
/**
* A service for managing and observing the current application theme.
@@ -9,9 +9,9 @@ import { ThemeType } from "@bitwarden/common/platform/enums";
export abstract class AbstractThemingService {
/**
* The effective theme based on the user configured choice and the current system theme if
* the configured choice is {@link ThemeType.System}.
* the configured choice is {@link ThemeTypes.System}.
*/
abstract theme$: Observable<ThemeType>;
abstract theme$: Observable<Theme>;
/**
* Listens for effective theme changes and applies changes to the provided document.
* @param document The document that should have theme classes applied to it.

View File

@@ -8,7 +8,7 @@ import {
AbstractStorageService,
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { VaultTimeout } from "@bitwarden/common/types/vault-timeout.type";
@@ -47,7 +47,7 @@ export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
export const LOG_MAC_FAILURES = new SafeInjectionToken<boolean>("LOG_MAC_FAILURES");
export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<ThemeType>>(
export const SYSTEM_THEME_OBSERVABLE = new SafeInjectionToken<Observable<Theme>>(
"SYSTEM_THEME_OBSERVABLE",
);
export const DEFAULT_VAULT_TIMEOUT = new SafeInjectionToken<VaultTimeout>("DEFAULT_VAULT_TIMEOUT");

View File

@@ -178,6 +178,7 @@ import { BulkEncryptServiceImplementation } from "@bitwarden/common/platform/ser
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
import { DefaultBroadcasterService } from "@bitwarden/common/platform/services/default-broadcaster.service";
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service";
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
@@ -921,7 +922,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalMasterPasswordServiceAbstraction,
useClass: MasterPasswordService,
deps: [StateProvider, StateServiceAbstraction, KeyGenerationServiceAbstraction, EncryptService],
deps: [
StateProvider,
StateServiceAbstraction,
KeyGenerationServiceAbstraction,
EncryptService,
LogService,
],
}),
safeProvider({
provide: MasterPasswordServiceAbstraction,
@@ -1316,6 +1323,11 @@ const safeProviders: SafeProvider[] = [
InternalUserDecryptionOptionsServiceAbstraction,
],
}),
safeProvider({
provide: DefaultServerSettingsService,
useClass: DefaultServerSettingsService,
deps: [ConfigService],
}),
safeProvider({
provide: RegisterRouteService,
useClass: RegisterRouteService,

View File

@@ -14,7 +14,8 @@ import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { EventType } from "@bitwarden/common/enums";
import { ClientType, 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";
@@ -36,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -71,6 +73,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
restorePromise: Promise<any>;
checkPasswordPromise: Promise<number>;
showPassword = false;
showPrivateKey = false;
showTotpSeed = false;
showCardNumber = false;
showCardCode = false;
@@ -134,6 +137,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
{ name: i18nService.t("typeIdentity"), value: CipherType.Identity },
{ name: i18nService.t("typeSecureNote"), value: CipherType.SecureNote },
];
this.cardBrandOptions = [
{ name: "-- " + i18nService.t("select") + " --", value: null },
{ name: "Visa", value: "Visa" },
@@ -200,6 +204,11 @@ 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 (this.platformUtilsService.getClientType() == ClientType.Desktop && sshKeysEnabled) {
this.typeOptions.push({ name: this.i18nService.t("typeSshKey"), value: CipherType.SshKey });
}
}
ngOnDestroy() {
@@ -279,6 +288,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.identity = new IdentityView();
this.cipher.secureNote = new SecureNoteView();
this.cipher.secureNote.type = SecureNoteType.Generic;
this.cipher.sshKey = new SshKeyView();
this.cipher.reprompt = CipherRepromptType.None;
}
}
@@ -601,6 +611,10 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
toggleUriOptions(uri: LoginUriView) {
const u = uri as any;
u.showOptions = u.showOptions == null && uri.match != null ? false : !u.showOptions;
@@ -699,7 +713,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
}
protected deleteCipher() {
const asAdmin = this.organization?.canEditAllCiphers;
const asAdmin = this.organization?.canEditAllCiphers || !this.cipher.collectionIds;
return this.cipher.isDeleted
? this.cipherService.deleteWithServer(this.cipher.id, asAdmin)
: this.cipherService.softDeleteWithServer(this.cipher.id, asAdmin);

View File

@@ -60,6 +60,7 @@ export class ViewComponent implements OnDestroy, OnInit {
showPasswordCount: boolean;
showCardNumber: boolean;
showCardCode: boolean;
showPrivateKey: boolean;
canAccessPremium: boolean;
showPremiumRequiredTotp: boolean;
totpCode: string;
@@ -325,6 +326,10 @@ export class ViewComponent implements OnDestroy, OnInit {
}
}
togglePrivateKey() {
this.showPrivateKey = !this.showPrivateKey;
}
async checkPassword() {
if (
this.cipher.login == null ||

View File

@@ -55,3 +55,6 @@ export * from "./lock/lock-component.service";
// vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component";
// self hosted environment configuration dialog
export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";

View File

@@ -7,7 +7,7 @@ import { BehaviorSubject, firstValueFrom, Subject, switchMap, take, takeUntil }
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.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";
@@ -26,7 +26,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
import { SyncService } from "@bitwarden/common/platform/sync";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import {
AsyncActionsModule,
@@ -73,7 +72,7 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
export class LockV2Component implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
activeAccount: { id: UserId | undefined } & AccountInfo;
activeAccount: Account | null;
clientType: ClientType;
ClientType = ClientType;
@@ -202,11 +201,15 @@ export class LockV2Component implements OnInit, OnDestroy {
.subscribe();
}
private async handleActiveAccountChange(activeAccount: { id: UserId | undefined } & AccountInfo) {
private async handleActiveAccountChange(activeAccount: Account | null) {
this.activeAccount = activeAccount;
this.resetDataOnActiveAccountChange();
if (activeAccount == null) {
return;
}
this.setEmailAsPageSubtitle(activeAccount.email);
this.unlockOptions = await firstValueFrom(
@@ -480,6 +483,7 @@ export class LockV2Component implements OnInit, OnDestroy {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterPasswordVerificationResponse.masterKey,
this.activeAccount.id,
);
await this.setUserKeyAndContinue(userKey, true);
}

View File

@@ -4,13 +4,14 @@ import { RouterModule } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { RegisterRouteService } from "@bitwarden/auth/common";
import { DefaultServerSettingsService } from "@bitwarden/common/platform/services/default-server-settings.service";
import { LinkModule } from "@bitwarden/components";
@Component({
standalone: true,
imports: [CommonModule, JslibModule, LinkModule, RouterModule],
template: `
<div class="tw-text-center">
<div class="tw-text-center" *ngIf="!(isUserRegistrationDisabled$ | async)">
{{ "newToBitwarden" | i18n }}
<a bitLink [routerLink]="registerRoute$ | async">{{ "createAccount" | i18n }}</a>
</div>
@@ -18,7 +19,10 @@ import { LinkModule } from "@bitwarden/components";
})
export class LoginSecondaryContentComponent {
registerRouteService = inject(RegisterRouteService);
serverSettingsService = inject(DefaultServerSettingsService);
// TODO: remove when email verification flag is removed
protected registerRoute$ = this.registerRouteService.registerRoute$();
protected isUserRegistrationDisabled$ = this.serverSettingsService.isUserRegistrationDisabled$;
}

View File

@@ -11,7 +11,7 @@
-->
<form [bitSubmit]="submit" [formGroup]="formGroup">
<ng-container *ngIf="loginUiState === LoginUiState.EMAIL_ENTRY">
<div [ngClass]="{ 'tw-invisible tw-h-0': loginUiState !== LoginUiState.EMAIL_ENTRY }">
<!-- Email Address input -->
<bit-form-field>
<bit-label>{{ "emailAddress" | i18n }}</bit-label>
@@ -82,9 +82,9 @@
</button>
</ng-container>
</div>
</ng-container>
</div>
<ng-container *ngIf="loginUiState === LoginUiState.MASTER_PASSWORD_ENTRY">
<div [ngClass]="{ 'tw-invisible tw-h-0': loginUiState !== LoginUiState.MASTER_PASSWORD_ENTRY }">
<!-- Master Password input -->
<bit-form-field class="!tw-mb-1">
<bit-label>{{ "masterPass" | i18n }}</bit-label>
@@ -140,5 +140,5 @@
</button>
</ng-container>
</div>
</ng-container>
</div>
</form>

View File

@@ -2,7 +2,7 @@ import { CommonModule } from "@angular/common";
import { Component, ElementRef, Input, 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 } from "rxjs";
import { firstValueFrom, Subject, take, takeUntil, tap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -18,14 +18,18 @@ import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstraction
import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { ClientType } from "@bitwarden/common/enums";
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 { 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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { SyncService } from "@bitwarden/common/platform/sync";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
@@ -136,12 +140,17 @@ export class LoginComponent implements OnInit, OnDestroy {
private syncService: SyncService,
private toastService: ToastService,
private logService: LogService,
private validationService: ValidationService,
private configService: ConfigService,
) {
this.clientType = this.platformUtilsService.getClientType();
this.loginViaAuthRequestSupported = this.loginComponentService.isLoginViaAuthRequestSupported();
}
async ngOnInit(): Promise<void> {
// TODO: remove this when the UnauthenticatedExtensionUIRefresh feature flag is removed.
this.listenForUnauthUiRefreshFlagChanges();
await this.defaultOnInit();
if (this.clientType === ClientType.Desktop) {
@@ -159,6 +168,29 @@ 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 uniqueQueryParams = {
...this.activatedRoute.queryParams,
// 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) {
@@ -182,19 +214,54 @@ export class LoginComponent implements OnInit, OnDestroy {
null,
);
const authResult = await this.loginStrategyService.logIn(credentials);
try {
const authResult = await this.loginStrategyService.logIn(credentials);
await this.saveEmailSettings();
await this.handleAuthResult(authResult);
await this.saveEmailSettings();
await this.handleAuthResult(authResult);
if (this.clientType === ClientType.Desktop) {
if (this.captchaSiteKey) {
const content = document.getElementById("content") as HTMLDivElement;
content.setAttribute("style", "width:335px");
if (this.clientType === ClientType.Desktop) {
if (this.captchaSiteKey) {
const content = document.getElementById("content") as HTMLDivElement;
content.setAttribute("style", "width:335px");
}
}
} catch (error) {
this.logService.error(error);
this.handleSubmitError(error);
}
};
/**
* Handles the error from the submit function.
*
* @param error The error object.
*/
private handleSubmitError(error: unknown) {
// Handle error responses
if (error instanceof ErrorResponse) {
switch (error.statusCode) {
case HttpStatusCode.BadRequest: {
if (error.message.toLowerCase().includes("username or password is incorrect")) {
this.formGroup.controls.masterPassword.setErrors({
error: {
message: this.i18nService.t("invalidMasterPassword"),
},
});
}
break;
}
default: {
// Allow all other errors to be handled by toast
this.validationService.showError(error);
}
}
} else {
// Allow all other errors to be handled by toast
this.validationService.showError(error);
}
}
/**
* Handles the result of the authentication process.
*

View File

@@ -1,7 +1,7 @@
<form [formGroup]="formGroup" *ngIf="!hideEnvSelector">
<bit-form-field>
<bit-label>{{ "creatingAccountOn" | i18n }}</bit-label>
<bit-select formControlName="selectedRegion">
<bit-select formControlName="selectedRegion" (closed)="onSelectClosed()">
<bit-option
*ngFor="let regionConfig of availableRegionConfigs"
[value]="regionConfig"

View File

@@ -15,7 +15,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { DialogService, FormFieldModule, SelectModule, ToastService } from "@bitwarden/components";
import { RegistrationSelfHostedEnvConfigDialogComponent } from "./registration-self-hosted-env-config-dialog.component";
import { SelfHostedEnvConfigDialogComponent } from "../../self-hosted-env-config-dialog/self-hosted-env-config-dialog.component";
/**
* Component for selecting the environment to register with in the email verification registration flow.
@@ -109,6 +109,9 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
.subscribe();
}
/**
* Listens for changes to the selected region and updates the form value and emits the selected region.
*/
private listenForSelectedRegionChanges() {
this.selectedRegion.valueChanges
.pipe(
@@ -124,18 +127,12 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
return of(null);
}
if (selectedRegion === Region.SelfHosted) {
return from(
RegistrationSelfHostedEnvConfigDialogComponent.open(this.dialogService),
).pipe(
tap((result: boolean | undefined) =>
this.handleSelfHostedEnvConfigDialogResult(result, prevSelectedRegion),
),
);
if (selectedRegion !== Region.SelfHosted) {
this.selectedRegionChange.emit(selectedRegion);
return from(this.environmentService.setEnvironment(selectedRegion.key));
}
this.selectedRegionChange.emit(selectedRegion);
return from(this.environmentService.setEnvironment(selectedRegion.key));
return of(null);
},
),
takeUntil(this.destroy$),
@@ -172,6 +169,17 @@ export class RegistrationEnvSelectorComponent implements OnInit, OnDestroy {
}
}
/**
* Handles the event when the select is closed.
* If the selected region is self-hosted, opens the self-hosted environment settings dialog.
*/
protected async onSelectClosed() {
if (this.selectedRegion.value === Region.SelfHosted) {
const result = await SelfHostedEnvConfigDialogComponent.open(this.dialogService);
return this.handleSelfHostedEnvConfigDialogResult(result, this.selectedRegion.value);
}
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();

View File

@@ -10,7 +10,7 @@ import {
ValidationErrors,
ValidatorFn,
} from "@angular/forms";
import { Subject, firstValueFrom } from "rxjs";
import { Subject, firstValueFrom, take, filter, takeUntil } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -54,8 +54,8 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
*/
@Component({
standalone: true,
selector: "auth-registration-self-hosted-env-config-dialog",
templateUrl: "registration-self-hosted-env-config-dialog.component.html",
selector: "self-hosted-env-config-dialog",
templateUrl: "self-hosted-env-config-dialog.component.html",
imports: [
CommonModule,
JslibModule,
@@ -68,14 +68,14 @@ function selfHostedEnvSettingsFormValidator(): ValidatorFn {
AsyncActionsModule,
],
})
export class RegistrationSelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
export class SelfHostedEnvConfigDialogComponent implements OnInit, OnDestroy {
/**
* Opens the dialog.
* @param dialogService - Dialog service.
* @returns Promise that resolves to true if the dialog was closed with a successful result, false otherwise.
*/
static async open(dialogService: DialogService): Promise<boolean> {
const dialogRef = dialogService.open<boolean>(RegistrationSelfHostedEnvConfigDialogComponent, {
const dialogRef = dialogService.open<boolean>(SelfHostedEnvConfigDialogComponent, {
disableClose: false,
});
@@ -131,7 +131,33 @@ export class RegistrationSelfHostedEnvConfigDialogComponent implements OnInit, O
private environmentService: EnvironmentService,
) {}
ngOnInit() {}
ngOnInit() {
/**
* Populate the form with the current self-hosted environment settings.
*/
this.environmentService.environment$
.pipe(
take(1),
filter((env) => {
const region = env.getRegion();
return region === Region.SelfHosted;
}),
takeUntil(this.destroy$),
)
.subscribe({
next: (env) => {
const urls = env.getUrls();
this.formGroup.patchValue({
baseUrl: urls.base || "",
webVaultUrl: urls.webVault || "",
apiUrl: urls.api || "",
identityUrl: urls.identity || "",
iconsUrl: urls.icons || "",
notificationsUrl: urls.notifications || "",
});
},
});
}
submit = async () => {
this.showErrorSummary = false;

View File

@@ -114,7 +114,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
private async trySetUserKeyWithMasterKey(userId: UserId): Promise<void> {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -183,7 +183,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -496,7 +496,7 @@ describe("SsoLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
@@ -552,7 +552,7 @@ describe("SsoLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);

View File

@@ -338,7 +338,7 @@ export class SsoLoginStrategy extends LoginStrategy {
return;
}
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
await this.keyService.setUserKey(userKey, userId);
}

View File

@@ -213,7 +213,7 @@ describe("UserApiLoginStrategy", () => {
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
masterKey,
undefined,
userId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);

View File

@@ -69,7 +69,10 @@ export class UserApiLoginStrategy extends LoginStrategy {
if (response.apiUseKeyConnector) {
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (masterKey) {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
);
await this.keyService.setUserKey(userKey, userId);
}
}

View File

@@ -200,7 +200,7 @@ describe("AuthRequestService", () => {
);
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
mockDecryptedMasterKey,
undefined,
mockUserId,
undefined,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockDecryptedUserKey, mockUserId);

View File

@@ -150,7 +150,7 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
);
// Decrypt and set user key in state
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey);
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(masterKey, userId);
// Set masterKey + masterKeyHash in state after decryption (in case decryption fails)
await this.masterPasswordService.setMasterKey(masterKey, userId);

View File

@@ -295,7 +295,7 @@ describe("LoginStrategyService", () => {
new IdentityTokenResponse({
ForcePasswordReset: false,
Kdf: KdfType.PBKDF2_SHA256,
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min - 1,
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
Key: "KEY",
PrivateKey: "PRIVATE_KEY",
ResetMasterPassword: false,
@@ -309,7 +309,7 @@ describe("LoginStrategyService", () => {
apiService.postPrelogin.mockResolvedValue(
new PreloginResponse({
Kdf: KdfType.PBKDF2_SHA256,
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min - 1,
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
}),
);
@@ -321,7 +321,7 @@ describe("LoginStrategyService", () => {
});
await expect(sut.logIn(credentials)).rejects.toThrow(
`PBKDF2 iterations must be between ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min} and ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.max}`,
`PBKDF2 iterations must be at least ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1}; possible pre-login downgrade attack detected.`,
);
});
});

View File

@@ -418,6 +418,7 @@ export class PinService implements PinServiceAbstraction {
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
userId,
encUserKey ? new EncString(encUserKey) : undefined,
);

View File

@@ -1,7 +1,7 @@
import { mock } from "jest-mock-extended";
import { ReplaySubject, combineLatest, map } from "rxjs";
import { AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid";
export function mockAccountServiceWith(
@@ -30,7 +30,7 @@ export class FakeAccountService implements AccountService {
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountsSubject = new ReplaySubject<Record<UserId, AccountInfo>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
activeAccountSubject = new ReplaySubject<{ id: UserId } & AccountInfo>(1);
activeAccountSubject = new ReplaySubject<Account | null>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
private _activeUserId: UserId;

View File

@@ -6,6 +6,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -40,6 +41,9 @@ export class OrganizationApiServiceAbstraction {
getLicense: (id: string, installationId: string) => Promise<unknown>;
getAutoEnrollStatus: (identifier: string) => Promise<OrganizationAutoEnrollStatusResponse>;
create: (request: OrganizationCreateRequest) => Promise<OrganizationResponse>;
createWithoutPayment: (
request: OrganizationNoPaymentMethodCreateRequest,
) => Promise<OrganizationResponse>;
createLicense: (data: FormData) => Promise<OrganizationResponse>;
save: (id: string, request: OrganizationUpdateRequest) => Promise<OrganizationResponse>;
updatePayment: (id: string, request: PaymentRequest) => Promise<void>;

View File

@@ -1,4 +1,5 @@
export enum ProviderType {
Msp = 0,
Reseller = 1,
MultiOrganizationEnterprise = 2,
}

View File

@@ -1,32 +1,7 @@
import { PaymentMethodType, PlanType } from "../../../billing/enums";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PaymentMethodType } from "../../../billing/enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationKeysRequest } from "./organization-keys.request";
export class OrganizationCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
export class OrganizationCreateRequest extends OrganizationNoPaymentMethodCreateRequest {
paymentMethodType: PaymentMethodType;
paymentToken: string;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -8,6 +8,7 @@ export class PolicyResponse extends BaseResponse {
type: PolicyType;
data: any;
enabled: boolean;
canToggleState: boolean;
constructor(response: any) {
super(response);
@@ -16,5 +17,6 @@ export class PolicyResponse extends BaseResponse {
this.type = this.getResponseProperty("Type");
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
}
}

View File

@@ -7,6 +7,7 @@ import { SecretVerificationRequest } from "../../../auth/models/request/secret-v
import { ApiKeyResponse } from "../../../auth/models/response/api-key.response";
import { OrganizationSsoResponse } from "../../../auth/models/response/organization-sso.response";
import { ExpandedTaxInfoUpdateRequest } from "../../../billing/models/request/expanded-tax-info-update.request";
import { OrganizationNoPaymentMethodCreateRequest } from "../../../billing/models/request/organization-no-payment-method-create-request";
import { OrganizationSmSubscriptionUpdateRequest } from "../../../billing/models/request/organization-sm-subscription-update.request";
import { OrganizationSubscriptionUpdateRequest } from "../../../billing/models/request/organization-subscription-update.request";
import { PaymentRequest } from "../../../billing/models/request/payment.request";
@@ -107,6 +108,21 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
return new OrganizationResponse(r);
}
async createWithoutPayment(
request: OrganizationNoPaymentMethodCreateRequest,
): Promise<OrganizationResponse> {
const r = await this.apiService.send(
"POST",
"/organizations/create-without-payment",
request,
true,
true,
);
// Forcing a sync will notify organization service that they need to repull
await this.syncService.fullSync(true);
return new OrganizationResponse(r);
}
async createLicense(data: FormData): Promise<OrganizationResponse> {
const r = await this.apiService.send(
"POST",

View File

@@ -12,6 +12,8 @@ export type AccountInfo = {
name: string | undefined;
};
export type Account = { id: UserId } & AccountInfo;
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
@@ -32,7 +34,8 @@ export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
export abstract class AccountService {
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
activeAccount$: Observable<Account | null>;
/**
* Observable of the last activity time for each account.
@@ -41,7 +44,7 @@ export abstract class AccountService {
/** Account list in order of descending recency */
sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
nextUpAccount$: Observable<Account>;
/**
* Updates the `accounts$` observable with the new account data.
*

View File

@@ -33,16 +33,16 @@ export abstract class MasterPasswordServiceAbstraction {
/**
* Decrypts the user key with the provided master key
* @param masterKey The user's master key
* * @param userId The desired user
* @param userKey The user's encrypted symmetric key
* @param userId The desired user
* @throws If either the MasterKey or UserKey are not resolved, or if the UserKey encryption type
* is neither AesCbc256_B64 nor AesCbc256_HmacSha256_B64
* @returns The user key
*/
abstract decryptUserKeyWithMasterKey: (
masterKey: MasterKey,
userId: string,
userKey?: EncString,
userId?: string,
) => Promise<UserKey>;
}

View File

@@ -13,7 +13,7 @@ export type KdfConfig = PBKDF2KdfConfig | Argon2KdfConfig;
*/
export class PBKDF2KdfConfig {
static ITERATIONS = new RangeWithDefault(600_000, 2_000_000, 600_000);
static PRELOGIN_ITERATIONS = new RangeWithDefault(5000, 2_000_000, 600_000);
static PRELOGIN_ITERATIONS_MIN = 5000;
kdfType: KdfType.PBKDF2_SHA256 = KdfType.PBKDF2_SHA256;
iterations: number;
@@ -38,9 +38,9 @@ export class PBKDF2KdfConfig {
* A Valid PBKDF2 KDF configuration has KDF iterations between the 5000 and 2_000_000.
*/
validateKdfConfigForPrelogin(): void {
if (!PBKDF2KdfConfig.PRELOGIN_ITERATIONS.inRange(this.iterations)) {
if (PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN > this.iterations) {
throw new Error(
`PBKDF2 iterations must be between ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min} and ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.max}`,
`PBKDF2 iterations must be at least ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${this.iterations}; possible pre-login downgrade attack detected.`,
);
}
}
@@ -58,9 +58,9 @@ export class Argon2KdfConfig {
static PARALLELISM = new RangeWithDefault(1, 16, 4);
static ITERATIONS = new RangeWithDefault(2, 10, 3);
static PRELOGIN_MEMORY = Argon2KdfConfig.MEMORY;
static PRELOGIN_PARALLELISM = Argon2KdfConfig.PARALLELISM;
static PRELOGIN_ITERATIONS = Argon2KdfConfig.ITERATIONS;
static PRELOGIN_MEMORY_MIN = 16;
static PRELOGIN_PARALLELISM_MIN = 1;
static PRELOGIN_ITERATIONS_MIN = 2;
kdfType: KdfType.Argon2id = KdfType.Argon2id;
iterations: number;
@@ -86,7 +86,7 @@ export class Argon2KdfConfig {
if (!Argon2KdfConfig.MEMORY.inRange(this.memory)) {
throw new Error(
`Argon2 memory must be between ${Argon2KdfConfig.MEMORY.min}mb and ${Argon2KdfConfig.MEMORY.max}mb`,
`Argon2 memory must be between ${Argon2KdfConfig.MEMORY.min} MiB and ${Argon2KdfConfig.MEMORY.max} MiB`,
);
}
@@ -101,21 +101,21 @@ export class Argon2KdfConfig {
* Validates the Argon2 KDF configuration for pre-login.
*/
validateKdfConfigForPrelogin(): void {
if (!Argon2KdfConfig.PRELOGIN_ITERATIONS.inRange(this.iterations)) {
if (Argon2KdfConfig.PRELOGIN_ITERATIONS_MIN > this.iterations) {
throw new Error(
`Argon2 iterations must be between ${Argon2KdfConfig.PRELOGIN_ITERATIONS.min} and ${Argon2KdfConfig.PRELOGIN_ITERATIONS.max}`,
`Argon2 iterations must be at least ${Argon2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${this.iterations}; possible pre-login downgrade attack detected.`,
);
}
if (!Argon2KdfConfig.PRELOGIN_MEMORY.inRange(this.memory)) {
if (Argon2KdfConfig.PRELOGIN_MEMORY_MIN > this.memory) {
throw new Error(
`Argon2 memory must be between ${Argon2KdfConfig.PRELOGIN_MEMORY.min}mb and ${Argon2KdfConfig.PRELOGIN_MEMORY.max}mb`,
`Argon2 memory must be at least ${Argon2KdfConfig.PRELOGIN_MEMORY_MIN} MiB, but was ${this.memory} MiB; possible pre-login downgrade attack detected.`,
);
}
if (!Argon2KdfConfig.PRELOGIN_PARALLELISM.inRange(this.parallelism)) {
if (Argon2KdfConfig.PRELOGIN_PARALLELISM_MIN > this.parallelism) {
throw new Error(
`Argon2 parallelism must be between ${Argon2KdfConfig.PRELOGIN_PARALLELISM.min} and ${Argon2KdfConfig.PRELOGIN_PARALLELISM.max}.`,
`Argon2 parallelism must be at least ${Argon2KdfConfig.PRELOGIN_PARALLELISM_MIN}, but was ${this.parallelism}; possible pre-login downgrade attack detected.`,
);
}
}

View File

@@ -88,10 +88,10 @@ describe("accountService", () => {
});
describe("activeAccount$", () => {
it("should emit undefined if no account is active", () => {
it("should emit null if no account is active", () => {
const emissions = trackEmissions(sut.activeAccount$);
expect(emissions).toEqual([undefined]);
expect(emissions).toEqual([null]);
});
it("should emit the active account", async () => {
@@ -100,7 +100,7 @@ describe("accountService", () => {
activeAccountIdState.stateSubject.next(userId);
expect(emissions).toEqual([
undefined, // initial value
null, // initial value
{ id: userId, ...userInfo },
]);
});
@@ -258,10 +258,10 @@ describe("accountService", () => {
activeAccountIdState.stateSubject.next(userId);
});
it("should emit undefined if no account is provided", async () => {
it("should emit null if no account is provided", async () => {
await sut.switchAccount(null);
const currentState = await firstValueFrom(sut.activeAccount$);
expect(currentState).toBeUndefined();
expect(currentState).toBeNull();
});
it("should throw if the account does not exist", () => {

View File

@@ -8,6 +8,7 @@ import {
} from "rxjs";
import {
Account,
AccountInfo,
InternalAccountService,
accountInfoEqual,
@@ -45,15 +46,33 @@ const LOGGED_OUT_INFO: AccountInfo = {
name: undefined,
};
/**
* An rxjs map operator that extracts the UserId from an account, or throws if the account or UserId are null.
*/
export const getUserId = map<Account | null, UserId>((account) => {
if (account == null) {
throw new Error("Null or undefined account");
}
return account.id;
});
/**
* An rxjs map operator that extracts the UserId from an account, or returns undefined if the account or UserId are null.
*/
export const getOptionalUserId = map<Account | null, UserId | null>(
(account) => account?.id ?? null,
);
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<{ id: UserId | undefined } & AccountInfo>;
activeAccount$: Observable<Account | null>;
accountActivity$: Observable<Record<UserId, Date>>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<{ id: UserId } & AccountInfo>;
nextUpAccount$: Observable<Account>;
constructor(
private messagingService: MessagingService,
@@ -68,7 +87,7 @@ export class AccountServiceImplementation implements InternalAccountService {
);
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? { id, ...(accounts[id] as AccountInfo) } : undefined)),
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);

View File

@@ -82,13 +82,6 @@ describe("KdfConfigService", () => {
);
});
it("validateKdfConfigForSetting(): should throw an error for invalid Argon2 memory", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(3, 1025, 4);
expect(() => kdfConfig.validateKdfConfigForSetting()).toThrow(
`Argon2 memory must be between ${Argon2KdfConfig.MEMORY.min}mb and ${Argon2KdfConfig.MEMORY.max}mb`,
);
});
it("validateKdfConfigForSetting(): should throw an error for invalid Argon2 parallelism", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(3, 64, 17);
expect(() => kdfConfig.validateKdfConfigForSetting()).toThrow(
@@ -108,70 +101,32 @@ describe("KdfConfigService", () => {
it("validateKdfConfigForPrelogin(): should throw an error for too low PBKDF2 iterations", () => {
const kdfConfig: PBKDF2KdfConfig = new PBKDF2KdfConfig(
PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min - 1,
PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`PBKDF2 iterations must be between ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min} and ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.max}`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too high PBKDF2 iterations", () => {
const kdfConfig: PBKDF2KdfConfig = new PBKDF2KdfConfig(
PBKDF2KdfConfig.PRELOGIN_ITERATIONS.max + 1,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`PBKDF2 iterations must be between ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.min} and ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS.max}`,
`PBKDF2 iterations must be at least ${PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${kdfConfig.iterations}; possible pre-login downgrade attack detected.`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too low Argon2 iterations", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(
Argon2KdfConfig.ITERATIONS.min - 1,
Argon2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
64,
4,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`Argon2 iterations must be between ${Argon2KdfConfig.ITERATIONS.min} and ${Argon2KdfConfig.ITERATIONS.max}`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too high Argon2 iterations", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(
Argon2KdfConfig.PRELOGIN_ITERATIONS.max + 1,
64,
4,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`Argon2 iterations must be between ${Argon2KdfConfig.ITERATIONS.min} and ${Argon2KdfConfig.ITERATIONS.max}`,
`Argon2 iterations must be at least ${Argon2KdfConfig.PRELOGIN_ITERATIONS_MIN}, but was ${kdfConfig.iterations}; possible pre-login downgrade attack detected.`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too low Argon2 memory", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(
3,
Argon2KdfConfig.PRELOGIN_MEMORY.min - 1,
Argon2KdfConfig.PRELOGIN_MEMORY_MIN - 1,
4,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`Argon2 memory must be between ${Argon2KdfConfig.PRELOGIN_MEMORY.min}mb and ${Argon2KdfConfig.PRELOGIN_MEMORY.max}mb`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too high Argon2 memory", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(
3,
Argon2KdfConfig.PRELOGIN_MEMORY.max + 1,
4,
);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`Argon2 memory must be between ${Argon2KdfConfig.PRELOGIN_MEMORY.min}mb and ${Argon2KdfConfig.PRELOGIN_MEMORY.max}mb`,
);
});
it("validateKdfConfigForPrelogin(): should throw an error for too high Argon2 parallelism", () => {
const kdfConfig: Argon2KdfConfig = new Argon2KdfConfig(3, 64, 17);
expect(() => kdfConfig.validateKdfConfigForPrelogin()).toThrow(
`Argon2 parallelism must be between ${Argon2KdfConfig.PRELOGIN_PARALLELISM.min} and ${Argon2KdfConfig.PRELOGIN_PARALLELISM.max}`,
`Argon2 memory must be at least ${Argon2KdfConfig.PRELOGIN_MEMORY_MIN} MiB, but was ${kdfConfig.memory} MiB; possible pre-login downgrade attack detected.`,
);
});
});

View File

@@ -64,9 +64,9 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
decryptUserKeyWithMasterKey(
masterKey: MasterKey,
userId: string,
userKey?: EncString,
userId?: string,
): Promise<UserKey> {
return this.mock.decryptUserKeyWithMasterKey(masterKey, userKey, userId);
return this.mock.decryptUserKeyWithMasterKey(masterKey, userId, userKey);
}
}

View File

@@ -1,5 +1,7 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
import { StateService } from "../../../platform/abstractions/state.service";
@@ -55,6 +57,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
private stateService: StateService,
private keyGenerationService: KeyGenerationService,
private encryptService: EncryptService,
private logService: LogService,
) {}
masterKey$(userId: UserId): Observable<MasterKey> {
@@ -149,10 +152,9 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
async decryptUserKeyWithMasterKey(
masterKey: MasterKey,
userId: UserId,
userKey?: EncString,
userId?: UserId,
): Promise<UserKey> {
userId ??= await firstValueFrom(this.stateProvider.activeUserId$);
userKey ??= await this.getMasterKeyEncryptedUserKey(userId);
masterKey ??= await firstValueFrom(this.masterKey$(userId));
@@ -185,6 +187,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
}
if (decUserKey == null) {
this.logService.warning("Failed to decrypt user key with master key.");
return null;
}

View File

@@ -9,12 +9,12 @@ import { KeyService } from "../../../../key-management/src/abstractions/key.serv
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { AccountInfo, AccountService } from "../abstractions/account.service";
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
describe("PasswordResetEnrollmentServiceImplementation", () => {
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(null);
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
let accountService: MockProxy<AccountService>;

View File

@@ -216,7 +216,7 @@ describe("UserVerificationService", () => {
});
it("returns if verification is successful", async () => {
keyService.compareAndUpdateKeyHash.mockResolvedValueOnce(true);
keyService.compareKeyHash.mockResolvedValueOnce(true);
const result = await sut.verifyUserByMasterPassword(
{
@@ -227,7 +227,7 @@ describe("UserVerificationService", () => {
"email",
);
expect(keyService.compareAndUpdateKeyHash).toHaveBeenCalled();
expect(keyService.compareKeyHash).toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
"localHash",
mockUserId,
@@ -240,7 +240,7 @@ describe("UserVerificationService", () => {
});
it("throws if verification fails", async () => {
keyService.compareAndUpdateKeyHash.mockResolvedValueOnce(false);
keyService.compareKeyHash.mockResolvedValueOnce(false);
await expect(
sut.verifyUserByMasterPassword(
@@ -253,7 +253,7 @@ describe("UserVerificationService", () => {
),
).rejects.toThrow("Invalid master password");
expect(keyService.compareAndUpdateKeyHash).toHaveBeenCalled();
expect(keyService.compareKeyHash).toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
});
@@ -285,7 +285,7 @@ describe("UserVerificationService", () => {
"email",
);
expect(keyService.compareAndUpdateKeyHash).not.toHaveBeenCalled();
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
"localHash",
mockUserId,
@@ -318,7 +318,7 @@ describe("UserVerificationService", () => {
),
).rejects.toThrow("Invalid master password");
expect(keyService.compareAndUpdateKeyHash).not.toHaveBeenCalled();
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
});

View File

@@ -206,9 +206,10 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
let policyOptions: MasterPasswordPolicyResponse | null;
// Client-side verification
if (await this.hasMasterPasswordAndMasterKeyHash(userId)) {
const passwordValid = await this.keyService.compareAndUpdateKeyHash(
const passwordValid = await this.keyService.compareKeyHash(
verification.secret,
masterKey,
userId,
);
if (!passwordValid) {
throw new Error(this.i18nService.t("invalidMasterPassword"));

View File

@@ -1,4 +1,3 @@
export * from "./account/billing-account-profile-state.service";
export * from "./billing-api.service.abstraction";
export * from "./organization-billing.service";
export * from "./provider-billing.service.abstraction";

View File

@@ -44,4 +44,8 @@ export abstract class OrganizationBillingServiceAbstraction {
purchaseSubscription: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
startFree: (subscription: SubscriptionInformation) => Promise<OrganizationResponse>;
purchaseSubscriptionNoPaymentMethod: (
subscription: SubscriptionInformation,
) => Promise<OrganizationResponse>;
}

View File

@@ -1,23 +0,0 @@
import { map, Observable, OperatorFunction, switchMap } from "rxjs";
import { ProviderStatusType } from "@bitwarden/common/admin-console/enums";
import { Provider } from "@bitwarden/common/admin-console/models/domain/provider";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
type MaybeProvider = Provider | undefined;
export const hasConsolidatedBilling = (
configService: ConfigService,
): OperatorFunction<MaybeProvider, boolean> =>
switchMap<MaybeProvider, Observable<boolean>>((provider) =>
configService
.getFeatureFlag$(FeatureFlag.EnableConsolidatedBilling)
.pipe(
map((consolidatedBillingEnabled) =>
provider
? provider.providerStatus === ProviderStatusType.Billable && consolidatedBillingEnabled
: false,
),
),
);

View File

@@ -0,0 +1,29 @@
import { OrganizationKeysRequest } from "../../../admin-console/models/request/organization-keys.request";
import { InitiationPath } from "../../../models/request/reference-event.request";
import { PlanType } from "../../enums";
export class OrganizationNoPaymentMethodCreateRequest {
name: string;
businessName: string;
billingEmail: string;
planType: PlanType;
key: string;
keys: OrganizationKeysRequest;
additionalSeats: number;
maxAutoscaleSeats: number;
additionalStorageGb: number;
premiumAccessAddon: boolean;
collectionName: string;
taxIdNumber: string;
billingAddressLine1: string;
billingAddressLine2: string;
billingAddressCity: string;
billingAddressState: string;
billingAddressPostalCode: string;
billingAddressCountry: string;
useSecretsManager: boolean;
additionalSmSeats: number;
additionalServiceAccounts: number;
isFromSecretsManagerTrial: boolean;
initiationPath: InitiationPath;
}

View File

@@ -2,11 +2,17 @@ import { BaseResponse } from "../../../models/response/base.response";
export class OrganizationBillingMetadataResponse extends BaseResponse {
isEligibleForSelfHost: boolean;
isManaged: boolean;
isOnSecretsManagerStandalone: boolean;
isSubscriptionUnpaid: boolean;
hasSubscription: boolean;
constructor(response: any) {
super(response);
this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost");
this.isManaged = this.getResponseProperty("IsManaged");
this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone");
this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid");
this.hasSubscription = this.getResponseProperty("HasSubscription");
}
}

View File

@@ -1,3 +1,5 @@
import { ProviderType } from "@bitwarden/common/admin-console/enums";
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { SubscriptionSuspensionResponse } from "@bitwarden/common/billing/models/response/subscription-suspension.response";
import { TaxInfoResponse } from "@bitwarden/common/billing/models/response/tax-info.response";
@@ -13,6 +15,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
taxInformation?: TaxInfoResponse;
cancelAt?: string;
suspension?: SubscriptionSuspensionResponse;
providerType: ProviderType;
constructor(response: any) {
super(response);
@@ -34,6 +37,7 @@ export class ProviderSubscriptionResponse extends BaseResponse {
if (suspension != null) {
this.suspension = new SubscriptionSuspensionResponse(suspension);
}
this.providerType = this.getResponseProperty("providerType");
}
}
@@ -44,6 +48,8 @@ export class ProviderPlanResponse extends BaseResponse {
purchasedSeats: number;
cost: number;
cadence: string;
type: PlanType;
productTier: ProductTierType;
constructor(response: any) {
super(response);
@@ -53,5 +59,7 @@ export class ProviderPlanResponse extends BaseResponse {
this.purchasedSeats = this.getResponseProperty("PurchasedSeats");
this.cost = this.getResponseProperty("Cost");
this.cadence = this.getResponseProperty("Cadence");
this.type = this.getResponseProperty("Type");
this.productTier = this.getResponseProperty("ProductTier");
}
}

View File

@@ -17,6 +17,7 @@ import {
SubscriptionInformation,
} from "../abstractions/organization-billing.service";
import { PlanType } from "../enums";
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
interface OrganizationKeys {
encryptedKey: EncString;
@@ -77,6 +78,28 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
return response;
}
async purchaseSubscriptionNoPaymentMethod(
subscription: SubscriptionInformation,
): Promise<OrganizationResponse> {
const request = new OrganizationNoPaymentMethodCreateRequest();
const organizationKeys = await this.makeOrganizationKeys();
this.setOrganizationKeys(request, organizationKeys);
this.setOrganizationInformation(request, subscription.organization);
this.setPlanInformation(request, subscription.plan);
const response = await this.organizationApiService.createWithoutPayment(request);
await this.apiService.refreshIdentityToken();
await this.syncService.fullSync(true);
return response;
}
private async makeOrganizationKeys(): Promise<OrganizationKeys> {
const [encryptedKey, key] = await this.keyService.makeOrgKey<OrgKey>();
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(key);
@@ -106,7 +129,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setOrganizationInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: OrganizationInformation,
): void {
request.name = information.name;
@@ -115,7 +138,10 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
request.initiationPath = information.initiationPath;
}
private setOrganizationKeys(request: OrganizationCreateRequest, keys: OrganizationKeys): void {
private setOrganizationKeys(
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
keys: OrganizationKeys,
): void {
request.key = keys.encryptedKey.encryptedString;
request.keys = new OrganizationKeysRequest(
keys.publicKey,
@@ -146,7 +172,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
}
private setPlanInformation(
request: OrganizationCreateRequest,
request: OrganizationCreateRequest | OrganizationNoPaymentMethodCreateRequest,
information: PlanInformation,
): void {
request.planType = information.type;

View File

@@ -56,6 +56,8 @@ export enum EventType {
OrganizationUser_Restored = 1512,
OrganizationUser_ApprovedAuthRequest = 1513,
OrganizationUser_RejectedAuthRequest = 1514,
OrganizationUser_Deleted = 1515,
OrganizationUser_Left = 1516,
Organization_Updated = 1600,
Organization_PurgedVault = 1601,

View File

@@ -7,7 +7,6 @@ export enum FeatureFlag {
BrowserFilelessImport = "browser-fileless-import",
ItemShare = "item-share",
GeneratorToolsModernization = "generator-tools-modernization",
EnableConsolidatedBilling = "enable-consolidated-billing",
AC1795_UpdatedSubscriptionStatusSection = "AC-1795_updated-subscription-status-section",
ExtensionRefresh = "extension-refresh",
PersistPopupView = "persist-popup-view",
@@ -17,7 +16,6 @@ export enum FeatureFlag {
InlineMenuFieldQualification = "inline-menu-field-qualification",
MemberAccessReport = "ac-2059-member-access-report",
TwoFactorComponentRefactor = "two-factor-component-refactor",
EnableTimeThreshold = "PM-5864-dollar-threshold",
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
ProviderClientVaultPrivacyBanner = "ac-2833-provider-client-vault-privacy-banner",
VaultBulkManagementAction = "vault-bulk-management-action",
@@ -28,6 +26,8 @@ export enum FeatureFlag {
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
SSHKeyVaultItem = "ssh-key-vault-item",
SSHAgent = "ssh-agent",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
CipherKeyEncryption = "cipher-key-encryption",
@@ -36,6 +36,11 @@ export enum FeatureFlag {
AccessIntelligence = "pm-13227-access-intelligence",
Pm13322AddPolicyDefinitions = "pm-13322-add-policy-definitions",
LimitCollectionCreationDeletionSplit = "pm-10863-limit-collection-creation-deletion-split",
CriticalApps = "pm-14466-risk-insights-critical-application",
TrialPaymentOptional = "PM-8163-trial-payment",
SecurityTasks = "security-tasks",
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -53,7 +58,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.BrowserFilelessImport]: FALSE,
[FeatureFlag.ItemShare]: FALSE,
[FeatureFlag.GeneratorToolsModernization]: FALSE,
[FeatureFlag.EnableConsolidatedBilling]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
@@ -63,7 +67,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
[FeatureFlag.MemberAccessReport]: FALSE,
[FeatureFlag.TwoFactorComponentRefactor]: FALSE,
[FeatureFlag.EnableTimeThreshold]: FALSE,
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
[FeatureFlag.ProviderClientVaultPrivacyBanner]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
@@ -74,6 +77,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.SSHKeyVaultItem]: FALSE,
[FeatureFlag.SSHAgent]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
@@ -82,6 +87,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.AccessIntelligence]: FALSE,
[FeatureFlag.Pm13322AddPolicyDefinitions]: FALSE,
[FeatureFlag.LimitCollectionCreationDeletionSplit]: FALSE,
[FeatureFlag.CriticalApps]: FALSE,
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -10,6 +10,7 @@ import { IdentityExport } from "./identity.export";
import { LoginExport } from "./login.export";
import { PasswordHistoryExport } from "./password-history.export";
import { SecureNoteExport } from "./secure-note.export";
import { SshKeyExport } from "./ssh-key.export";
import { safeGetString } from "./utils";
export class CipherExport {
@@ -27,6 +28,7 @@ export class CipherExport {
req.secureNote = null;
req.card = null;
req.identity = null;
req.sshKey = null;
req.reprompt = CipherRepromptType.None;
req.passwordHistory = [];
req.creationDate = null;
@@ -67,6 +69,8 @@ export class CipherExport {
case CipherType.Identity:
view.identity = IdentityExport.toView(req.identity);
break;
case CipherType.SshKey:
view.sshKey = SshKeyExport.toView(req.sshKey);
}
if (req.passwordHistory != null) {
@@ -108,6 +112,9 @@ export class CipherExport {
case CipherType.Identity:
domain.identity = IdentityExport.toDomain(req.identity);
break;
case CipherType.SshKey:
domain.sshKey = SshKeyExport.toDomain(req.sshKey);
break;
}
if (req.passwordHistory != null) {
@@ -132,6 +139,7 @@ export class CipherExport {
secureNote: SecureNoteExport;
card: CardExport;
identity: IdentityExport;
sshKey: SshKeyExport;
reprompt: CipherRepromptType;
passwordHistory: PasswordHistoryExport[] = null;
revisionDate: Date = null;
@@ -171,6 +179,9 @@ export class CipherExport {
case CipherType.Identity:
this.identity = new IdentityExport(o.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyExport(o.sshKey);
break;
}
if (o.passwordHistory != null) {

View File

@@ -0,0 +1,44 @@
import { SshKeyView as SshKeyView } from "@bitwarden/common/vault/models/view/ssh-key.view";
import { EncString } from "../../platform/models/domain/enc-string";
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
import { safeGetString } from "./utils";
export class SshKeyExport {
static template(): SshKeyExport {
const req = new SshKeyExport();
req.privateKey = "";
req.publicKey = "";
req.keyFingerprint = "";
return req;
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;
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;
return domain;
}
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(o?: SshKeyView | SshKeyDomain) {
if (o == null) {
return;
}
this.privateKey = safeGetString(o.privateKey);
this.publicKey = safeGetString(o.publicKey);
this.keyFingerprint = safeGetString(o.keyFingerprint);
}
}

View File

@@ -3,6 +3,7 @@ import { SemVer } from "semver";
import { FeatureFlag, FeatureFlagValueType } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ServerSettings } from "../../models/domain/server-settings";
import { Region } from "../environment.service";
import { ServerConfig } from "./server-config";
@@ -10,6 +11,8 @@ import { ServerConfig } from "./server-config";
export abstract class ConfigService {
/** The server config of the currently active user */
serverConfig$: Observable<ServerConfig | null>;
/** The server settings of the currently active user */
serverSettings$: Observable<ServerSettings | null>;
/** The cloud region of the currently active user */
cloudRegion$: Observable<Region>;
/**

View File

@@ -6,6 +6,7 @@ import {
ThirdPartyServerConfigData,
EnvironmentServerConfigData,
} from "../../models/data/server-config.data";
import { ServerSettings } from "../../models/domain/server-settings";
const dayInMilliseconds = 24 * 3600 * 1000;
@@ -16,6 +17,7 @@ export class ServerConfig {
environment?: EnvironmentServerConfigData;
utcDate: Date;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(serverConfigData: ServerConfigData) {
this.version = serverConfigData.version;
@@ -24,6 +26,7 @@ export class ServerConfig {
this.utcDate = new Date(serverConfigData.utcDate);
this.environment = serverConfigData.environment;
this.featureStates = serverConfigData.featureStates;
this.settings = serverConfigData.settings;
if (this.server?.name == null && this.server?.url == null) {
this.server = null;

View File

@@ -20,9 +20,14 @@ export abstract class SdkService {
* Retrieve a client initialized for a specific user.
* This client can be used for operations that require a user context, such as retrieving ciphers
* and operations involving crypto. It can also be used for operations that don't require a user context.
*
* **WARNING:** Do not use `firstValueFrom(userClient$)`! Any operations on the client must be done within the observable.
* The client will be destroyed when the observable is no longer subscribed to.
* Please let platform know if you need a client that is not destroyed when the observable is no longer subscribed to.
*
* @param userId
*/
abstract userClient$(userId: UserId): Observable<BitwardenClient>;
abstract failedToInitialize(): Promise<void>;
abstract failedToInitialize(category: string, error?: Error): Promise<void>;
}

View File

@@ -1,3 +1,6 @@
/**
* @deprecated prefer the `ThemeTypes` constants and `Theme` type over unsafe enum types
**/
export enum ThemeType {
System = "system",
Light = "light",
@@ -5,3 +8,13 @@ export enum ThemeType {
Nord = "nord",
SolarizedDark = "solarizedDark",
}
export const ThemeTypes = {
System: "system",
Light: "light",
Dark: "dark",
Nord: "nord",
SolarizedDark: "solarizedDark",
} as const;
export type Theme = (typeof ThemeTypes)[keyof typeof ThemeTypes];

View File

@@ -3,6 +3,7 @@
export type SharedFlags = {
showPasswordless?: boolean;
sdk?: boolean;
prereleaseBuild?: boolean;
};
// required to avoid linting errors when there are no flags

View File

@@ -0,0 +1 @@
export * from "./rxjs-operators";

View File

@@ -0,0 +1,58 @@
import { firstValueFrom, of } from "rxjs";
import { getById, getByIds } from "./rxjs-operators";
describe("custom rxjs operators", () => {
describe("getById", () => {
it("returns an object with a matching id", async () => {
const obs = of([
{
id: 1,
data: "one",
},
{
id: 2,
data: "two",
},
{
id: 3,
data: "three",
},
]).pipe(getById(2));
const result = await firstValueFrom(obs);
expect(result).toEqual({ id: 2, data: "two" });
});
});
describe("getByIds", () => {
it("returns an array of objects with matching ids", async () => {
const obs = of([
{
id: 1,
data: "one",
},
{
id: 2,
data: "two",
},
{
id: 3,
data: "three",
},
{
id: 4,
data: "four",
},
]).pipe(getByIds([2, 3]));
const result = await firstValueFrom(obs);
expect(result).toEqual([
{ id: 2, data: "two" },
{ id: 3, data: "three" },
]);
});
});
});

View File

@@ -0,0 +1,21 @@
import { map } from "rxjs";
/**
* An rxjs operator that extracts an object by ID from an array of objects.
* @param id The ID of the object to return.
* @returns The first object with a matching ID, or undefined if no matching object is present.
*/
export const getById = <TId, T extends { id: TId }>(id: TId) =>
map<T[], T | undefined>((objects) => objects.find((o) => o.id === id));
/**
* An rxjs operator that extracts a subset of objects by their IDs from an array of objects.
* @param id The IDs of the objects to return.
* @returns An array containing objects with matching IDs, or an empty array if there are no matching objects.
*/
export const getByIds = <TId, T extends { id: TId }>(ids: TId[]) => {
const idSet = new Set(ids);
return map<T[], T[]>((objects) => {
return objects.filter((o) => idSet.has(o.id));
});
};

View File

@@ -16,6 +16,9 @@ describe("ServerConfigData", () => {
name: "test",
url: "https://test.com",
},
settings: {
disableUserRegistration: false,
},
environment: {
cloudRegion: Region.EU,
vault: "https://vault.com",

View File

@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { Region } from "../../abstractions/environment.service";
import { ServerSettings } from "../domain/server-settings";
import {
ServerConfigResponse,
ThirdPartyServerConfigResponse,
@@ -15,6 +16,7 @@ export class ServerConfigData {
environment?: EnvironmentServerConfigData;
utcDate: string;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
this.version = serverConfigResponse?.version;
@@ -27,6 +29,7 @@ export class ServerConfigData {
? new EnvironmentServerConfigData(serverConfigResponse.environment)
: null;
this.featureStates = serverConfigResponse?.featureStates;
this.settings = new ServerSettings(serverConfigResponse.settings);
}
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {

View File

@@ -0,0 +1,20 @@
import { ServerSettings } from "./server-settings";
describe("ServerSettings", () => {
describe("disableUserRegistration", () => {
it("defaults disableUserRegistration to false", () => {
const settings = new ServerSettings();
expect(settings.disableUserRegistration).toBe(false);
});
it("sets disableUserRegistration to true when provided", () => {
const settings = new ServerSettings({ disableUserRegistration: true });
expect(settings.disableUserRegistration).toBe(true);
});
it("sets disableUserRegistration to false when provided", () => {
const settings = new ServerSettings({ disableUserRegistration: false });
expect(settings.disableUserRegistration).toBe(false);
});
});
});

View File

@@ -0,0 +1,7 @@
export class ServerSettings {
disableUserRegistration: boolean;
constructor(data?: ServerSettings) {
this.disableUserRegistration = data?.disableUserRegistration ?? false;
}
}

View File

@@ -1,6 +1,7 @@
import { AllowedFeatureFlagTypes } from "../../../enums/feature-flag.enum";
import { BaseResponse } from "../../../models/response/base.response";
import { Region } from "../../abstractions/environment.service";
import { ServerSettings } from "../domain/server-settings";
export class ServerConfigResponse extends BaseResponse {
version: string;
@@ -8,6 +9,7 @@ export class ServerConfigResponse extends BaseResponse {
server: ThirdPartyServerConfigResponse;
environment: EnvironmentServerConfigResponse;
featureStates: { [key: string]: AllowedFeatureFlagTypes } = {};
settings: ServerSettings;
constructor(response: any) {
super(response);
@@ -21,6 +23,7 @@ export class ServerConfigResponse extends BaseResponse {
this.server = new ThirdPartyServerConfigResponse(this.getResponseProperty("Server"));
this.environment = new EnvironmentServerConfigResponse(this.getResponseProperty("Environment"));
this.featureStates = this.getResponseProperty("FeatureStates");
this.settings = new ServerSettings(this.getResponseProperty("Settings"));
}
}

View File

@@ -28,6 +28,7 @@ import { Environment, EnvironmentService, Region } from "../../abstractions/envi
import { LogService } from "../../abstractions/log.service";
import { devFlagEnabled, devFlagValue } from "../../misc/flags";
import { ServerConfigData } from "../../models/data/server-config.data";
import { ServerSettings } from "../../models/domain/server-settings";
import { CONFIG_DISK, KeyDefinition, StateProvider, UserKeyDefinition } from "../../state";
export const RETRIEVAL_INTERVAL = devFlagEnabled("configRetrievalIntervalMs")
@@ -57,6 +58,8 @@ export class DefaultConfigService implements ConfigService {
serverConfig$: Observable<ServerConfig>;
serverSettings$: Observable<ServerSettings>;
cloudRegion$: Observable<Region>;
constructor(
@@ -111,6 +114,10 @@ export class DefaultConfigService implements ConfigService {
this.cloudRegion$ = this.serverConfig$.pipe(
map((config) => config?.environment?.cloudRegion ?? Region.US),
);
this.serverSettings$ = this.serverConfig$.pipe(
map((config) => config?.settings ?? new ServerSettings()),
);
}
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {

View File

@@ -0,0 +1,47 @@
import { of } from "rxjs";
import { ConfigService } from "../abstractions/config/config.service";
import { ServerSettings } from "../models/domain/server-settings";
import { DefaultServerSettingsService } from "./default-server-settings.service";
describe("DefaultServerSettingsService", () => {
let service: DefaultServerSettingsService;
let configServiceMock: { serverSettings$: any };
beforeEach(() => {
configServiceMock = { serverSettings$: of() };
service = new DefaultServerSettingsService(configServiceMock as ConfigService);
});
describe("getSettings$", () => {
it("returns server settings", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: true });
configServiceMock.serverSettings$ = of(mockSettings);
service.getSettings$().subscribe((settings) => {
expect(settings).toEqual(mockSettings);
});
});
});
describe("isUserRegistrationDisabled$", () => {
it("returns true when user registration is disabled", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: true });
configServiceMock.serverSettings$ = of(mockSettings);
service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
expect(isDisabled).toBe(true);
});
});
it("returns false when user registration is enabled", () => {
const mockSettings = new ServerSettings({ disableUserRegistration: false });
configServiceMock.serverSettings$ = of(mockSettings);
service.isUserRegistrationDisabled$.subscribe((isDisabled: boolean) => {
expect(isDisabled).toBe(false);
});
});
});
});

View File

@@ -0,0 +1,19 @@
import { Observable } from "rxjs";
import { map } from "rxjs/operators";
import { ConfigService } from "../abstractions/config/config.service";
import { ServerSettings } from "../models/domain/server-settings";
export class DefaultServerSettingsService {
constructor(private configService: ConfigService) {}
getSettings$(): Observable<ServerSettings> {
return this.configService.serverSettings$;
}
get isUserRegistrationDisabled$(): Observable<boolean> {
return this.getSettings$().pipe(
map((settings: ServerSettings) => settings.disableUserRegistration),
);
}
}

View File

@@ -3,7 +3,7 @@ import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of } from "rxjs";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { Account, AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { SyncService } from "../../../vault/abstractions/sync/sync.service.abstraction";
@@ -33,7 +33,7 @@ import { guidToRawFormat } from "./guid-utils";
const RpId = "bitwarden.com";
describe("FidoAuthenticatorService", () => {
const activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>({
const activeAccountSubject = new BehaviorSubject<Account | null>({
id: "testId" as UserId,
email: "test@example.com",
emailVerified: true,

View File

@@ -4,6 +4,36 @@ describe("Fido2 Utils", () => {
const asciiHelloWorldArray = [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100];
const b64HelloWorldString = "aGVsbG8gd29ybGQ=";
describe("bufferSourceToUint8Array(..)", () => {
it("should convert an ArrayBuffer", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
});
it("should convert an ArrayBuffer slice", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer.slice(8);
const out = Fido2Utils.bufferSourceToUint8Array(buffer);
expect(out).toEqual(new Uint8Array([114, 108, 100])); // 8th byte onwards
});
it("should pass through an Uint8Array", () => {
const typedArray = new Uint8Array(asciiHelloWorldArray);
const out = Fido2Utils.bufferSourceToUint8Array(typedArray);
expect(out).toEqual(new Uint8Array(asciiHelloWorldArray));
});
it("should preserve the view of TypedArray", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const input = new Uint8Array(buffer, 8, 1);
const out = Fido2Utils.bufferSourceToUint8Array(input);
expect(out).toEqual(new Uint8Array([114]));
});
it("should convert different TypedArrays", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;
const input = new Uint16Array(buffer, 8, 1);
const out = Fido2Utils.bufferSourceToUint8Array(input);
expect(out).toEqual(new Uint8Array([114, 108]));
});
});
describe("fromBufferToB64(...)", () => {
it("should convert an ArrayBuffer to a b64 string", () => {
const buffer = new Uint8Array(asciiHelloWorldArray).buffer;

View File

@@ -1,13 +1,6 @@
export class Fido2Utils {
static bufferToString(bufferSource: BufferSource): string {
let buffer: Uint8Array;
if (bufferSource instanceof ArrayBuffer || bufferSource.buffer === undefined) {
buffer = new Uint8Array(bufferSource as ArrayBuffer);
} else {
buffer = new Uint8Array(bufferSource.buffer);
}
return Fido2Utils.fromBufferToB64(buffer)
return Fido2Utils.fromBufferToB64(Fido2Utils.bufferSourceToUint8Array(bufferSource))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
@@ -18,12 +11,10 @@ export class Fido2Utils {
}
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
if (bufferSource instanceof Uint8Array) {
return bufferSource;
} else if (Fido2Utils.isArrayBuffer(bufferSource)) {
if (Fido2Utils.isArrayBuffer(bufferSource)) {
return new Uint8Array(bufferSource);
} else {
return new Uint8Array(bufferSource.buffer);
return new Uint8Array(bufferSource.buffer, bufferSource.byteOffset, bufferSource.byteLength);
}
}

View File

@@ -96,7 +96,7 @@ export class DefaultSdkService implements SdkService {
let client: BitwardenClient;
const createAndInitializeClient = async () => {
if (privateKey == null || userKey == null || orgKeys == null) {
if (privateKey == null || userKey == null) {
return undefined;
}
@@ -130,7 +130,7 @@ export class DefaultSdkService implements SdkService {
return client$;
}
async failedToInitialize(): Promise<void> {
async failedToInitialize(category: string, error?: Error): Promise<void> {
// Only log on cloud instances
if (
this.platformUtilsService.isDev() ||
@@ -139,9 +139,20 @@ export class DefaultSdkService implements SdkService {
return;
}
return this.apiService.send("POST", "/wasm-debug", null, false, false, null, (headers) => {
headers.append("SDK-Version", "1.0.0");
});
return this.apiService.send(
"POST",
"/wasm-debug",
{
category: category,
error: error?.message,
},
false,
false,
null,
(headers) => {
headers.append("SDK-Version", "1.0.0");
},
);
}
private async initializeClient(
@@ -150,7 +161,7 @@ export class DefaultSdkService implements SdkService {
kdfParams: KdfConfig,
privateKey: EncryptedString,
userKey: UserKey,
orgKeys: Record<OrganizationId, EncryptedOrganizationKeyData>,
orgKeys?: Record<OrganizationId, EncryptedOrganizationKeyData>,
) {
await client.crypto().initialize_user_crypto({
email: account.email,
@@ -169,9 +180,12 @@ export class DefaultSdkService implements SdkService {
},
privateKey,
});
// We initialize the org crypto even if the org_keys are
// null to make sure any existing org keys are cleared.
await client.crypto().initialize_org_crypto({
organizationKeys: new Map(
Object.entries(orgKeys)
Object.entries(orgKeys ?? {})
.filter(([_, v]) => v.type === "organization")
.map(([k, v]) => [k, v.key]),
),

View File

@@ -8,7 +8,7 @@ import { Jsonify } from "type-fest";
import { awaitAsync, trackEmissions } from "../../../../spec";
import { FakeStorageService } from "../../../../spec/fake-storage.service";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { Account } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
import { LogService } from "../../abstractions/log.service";
import { StorageServiceProvider } from "../../services/storage-service.provider";
@@ -47,7 +47,7 @@ describe("DefaultActiveUserState", () => {
const storageServiceProvider = mock<StorageServiceProvider>();
const stateEventRegistrarService = mock<StateEventRegistrarService>();
const logService = mock<LogService>();
let activeAccountSubject: BehaviorSubject<{ id: UserId } & AccountInfo>;
let activeAccountSubject: BehaviorSubject<Account | null>;
let singleUserStateProvider: DefaultSingleUserStateProvider;
@@ -63,7 +63,7 @@ describe("DefaultActiveUserState", () => {
logService,
);
activeAccountSubject = new BehaviorSubject<{ id: UserId } & AccountInfo>(undefined);
activeAccountSubject = new BehaviorSubject<Account | null>(null);
userState = new DefaultActiveUserState(
testKeyDefinition,
@@ -220,7 +220,8 @@ describe("DefaultActiveUserState", () => {
it("should not emit a previous users value if that user is no longer active", async () => {
const user1Data: Jsonify<TestState> = {
date: "2020-09-21T13:14:17.648Z",
array: ["value"],
// NOTE: `as any` is here until we migrate to Nx: https://bitwarden.atlassian.net/browse/PM-6493
array: ["value"] as any,
};
const user2Data: Jsonify<TestState> = {
date: "2020-09-21T13:14:17.648Z",

View File

@@ -192,7 +192,8 @@ describe("KeyDefinition", () => {
expect(arrayDefinition).toBeTruthy();
expect(arrayDefinition.deserializer).toBeTruthy();
const deserializedValue = arrayDefinition.deserializer([false, true]);
// NOTE: `as any` is here until we migrate to Nx: https://bitwarden.atlassian.net/browse/PM-6493
const deserializedValue = arrayDefinition.deserializer([false, true] as any);
expect(deserializedValue).toBeTruthy();
expect(deserializedValue).toHaveLength(2);

View File

@@ -126,6 +126,7 @@ import { AppIdService } from "../platform/abstractions/app-id.service";
import { EnvironmentService } from "../platform/abstractions/environment.service";
import { LogService } from "../platform/abstractions/log.service";
import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service";
import { flagEnabled } from "../platform/misc/flags";
import { Utils } from "../platform/misc/utils";
import { SyncResponse } from "../platform/sync";
import { UserId } from "../types/guid";
@@ -583,7 +584,7 @@ export class ApiService implements ApiServiceAbstraction {
}
putCipherCollectionsAdmin(id: string, request: CipherCollectionsRequest): Promise<any> {
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, false);
return this.send("PUT", "/ciphers/" + id + "/collections-admin", request, true, true);
}
postPurgeCiphers(
@@ -1843,44 +1844,20 @@ export class ApiService implements ApiServiceAbstraction {
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const headers = new Headers({
"Device-Type": this.deviceType,
});
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
}
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
authed,
hasResponse,
body,
alterHeaders,
);
const requestInit: RequestInit = {
cache: "no-store",
credentials: await this.getCredentials(),
method: method,
};
if (authed) {
const authHeader = await this.getActiveBearerToken();
headers.set("Authorization", "Bearer " + authHeader);
}
if (body != null) {
if (typeof body === "string") {
requestInit.body = body;
headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
} else if (typeof body === "object") {
if (body instanceof FormData) {
requestInit.body = body;
} else {
headers.set("Content-Type", "application/json; charset=utf-8");
requestInit.body = JSON.stringify(body);
}
}
}
if (hasResponse) {
headers.set("Accept", "application/json");
}
if (alterHeaders != null) {
alterHeaders(headers);
}
requestInit.headers = headers;
requestInit.headers = requestHeaders;
requestInit.body = requestBody;
const response = await this.fetch(new Request(requestUrl, requestInit));
const responseType = response.headers.get("content-type");
@@ -1897,6 +1874,51 @@ export class ApiService implements ApiServiceAbstraction {
}
}
private async buildHeadersAndBody(
authed: boolean,
hasResponse: boolean,
body: any,
alterHeaders: (headers: Headers) => void,
): Promise<[Headers, any]> {
let requestBody: any = null;
const headers = new Headers({
"Device-Type": this.deviceType,
});
if (flagEnabled("prereleaseBuild")) {
headers.set("Is-Prerelease", "1");
}
if (this.customUserAgent != null) {
headers.set("User-Agent", this.customUserAgent);
}
if (hasResponse) {
headers.set("Accept", "application/json");
}
if (alterHeaders != null) {
alterHeaders(headers);
}
if (authed) {
const authHeader = await this.getActiveBearerToken();
headers.set("Authorization", "Bearer " + authHeader);
}
if (body != null) {
if (typeof body === "string") {
requestBody = body;
headers.set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
} else if (typeof body === "object") {
if (body instanceof FormData) {
requestBody = body;
} else {
headers.set("Content-Type", "application/json; charset=utf-8");
requestBody = JSON.stringify(body);
}
}
}
return [headers, requestBody];
}
private async handleError(
response: Response,
tokenError: boolean,

View File

@@ -22,6 +22,7 @@ export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>>
classifier: Classifier<State, Disclosed, Secret>;
format: "plain" | "classified";
options: UserKeyDefinitionOptions<State>;
initial?: State;
};
export function isObjectKey(key: any): key is ObjectKey<unknown> {

View File

@@ -373,7 +373,11 @@ describe("UserStateSubject", () => {
singleUserId$.next(SomeUser);
await awaitAsync();
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
expect(state.nextMock).toHaveBeenCalledWith({
foo: "next",
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
@@ -394,7 +398,13 @@ describe("UserStateSubject", () => {
await awaitAsync();
const encrypted = { foo: "encrypt(next)" };
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
expect(state.nextMock).toHaveBeenCalledWith({
id: null,
secret: encrypted,
disclosed: null,
// FIXME: don't leak this detail into the test
"$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$": 0,
});
});
it("applies dynamic constraints", async () => {

View File

@@ -43,6 +43,23 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
// FIXME: The subject should always repeat the value when it's own `next` method is called.
//
// Chrome StateService only calls `next` when the underlying values changes. When enforcing,
// say, a minimum constraint, any value beneath the minimum becomes the minimum. This prevents
// invalid data received in sequence from calling `next` because the state provider doesn't
// emit.
//
// The hack is pretty simple. Insert arbitrary data into the saved data to ensure
// that it *always* changes.
//
// Any real fix will be fairly complex because it needs to recognize *fast* when it
// is waiting. Alternatively, the kludge could become a format properly fed by random noise.
//
// NOTE: this only matters for plaintext objects; encrypted fields change with every
// update b/c their IVs change.
const ALWAYS_UPDATE_KLUDGE = "$^$ALWAYS_UPDATE_KLUDGE_PROPERTY$^$";
/**
* Adapt a state provider to an rxjs subject.
*
@@ -254,17 +271,18 @@ export class UserStateSubject<
withConstraints,
map(([loadedState, constraints]) => {
// bypass nulls
if (!loadedState) {
if (!loadedState && !this.objectKey?.initial) {
return {
constraints: {} as Constraints<State>,
state: null,
} satisfies Constrained<State>;
}
const unconstrained = loadedState ?? structuredClone(this.objectKey.initial);
const calibration = isDynamic(constraints)
? constraints.calibrate(loadedState)
? constraints.calibrate(unconstrained)
: constraints;
const adjusted = calibration.adjust(loadedState);
const adjusted = calibration.adjust(unconstrained);
return {
constraints: calibration.constraints,
@@ -419,8 +437,25 @@ export class UserStateSubject<
private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable;
private counter = 0;
private onNext(value: unknown) {
this.state.update(() => value).catch((e: any) => this.onError(e));
this.state
.update(() => {
if (typeof value === "object") {
// related: ALWAYS_UPDATE_KLUDGE FIXME
const counter = this.counter++;
if (counter > Number.MAX_SAFE_INTEGER) {
this.counter = 0;
}
const kludge = value as any;
kludge[ALWAYS_UPDATE_KLUDGE] = counter;
}
return value;
})
.catch((e: any) => this.onError(e));
}
private onError(value: any) {

View File

@@ -119,7 +119,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
* Used for Unassigned ciphers or when the user only has admin access to the cipher (not assigned normally).
* @param cipher
*/
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<void>;
saveCollectionsWithServerAdmin: (cipher: Cipher) => Promise<Cipher>;
/**
* Bulk update collections for many ciphers with the server
* @param orgId

View File

@@ -3,4 +3,5 @@ export enum CipherType {
SecureNote = 2,
Card = 3,
Identity = 4,
SshKey = 5,
}

View File

@@ -67,6 +67,9 @@ export function buildCipherIcon(iconsServerUrl: string, cipher: CipherView, show
case CipherType.Identity:
icon = "bwi-id-card";
break;
case CipherType.SshKey:
icon = "bwi-key";
break;
default:
break;
}

View File

@@ -0,0 +1,17 @@
import { BaseResponse } from "../../../models/response/base.response";
export class SshKeyApi extends BaseResponse {
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(data: any = null) {
super(data);
if (data == null) {
return;
}
this.privateKey = this.getResponseProperty("PrivateKey");
this.publicKey = this.getResponseProperty("PublicKey");
this.keyFingerprint = this.getResponseProperty("KeyFingerprint");
}
}

View File

@@ -11,6 +11,7 @@ import { IdentityData } from "./identity.data";
import { LoginData } from "./login.data";
import { PasswordHistoryData } from "./password-history.data";
import { SecureNoteData } from "./secure-note.data";
import { SshKeyData } from "./ssh-key.data";
export class CipherData {
id: string;
@@ -28,6 +29,7 @@ export class CipherData {
secureNote?: SecureNoteData;
card?: CardData;
identity?: IdentityData;
sshKey?: SshKeyData;
fields?: FieldData[];
attachments?: AttachmentData[];
passwordHistory?: PasswordHistoryData[];
@@ -72,6 +74,9 @@ export class CipherData {
case CipherType.Identity:
this.identity = new IdentityData(response.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKeyData(response.sshKey);
break;
default:
break;
}

View File

@@ -0,0 +1,17 @@
import { SshKeyApi } from "../api/ssh-key.api";
export class SshKeyData {
privateKey: string;
publicKey: string;
keyFingerprint: string;
constructor(data?: SshKeyApi) {
if (data == null) {
return;
}
this.privateKey = data.privateKey;
this.publicKey = data.publicKey;
this.keyFingerprint = data.keyFingerprint;
}
}

View File

@@ -19,6 +19,7 @@ import { Identity } from "./identity";
import { Login } from "./login";
import { Password } from "./password";
import { SecureNote } from "./secure-note";
import { SshKey } from "./ssh-key";
export class Cipher extends Domain implements Decryptable<CipherView> {
readonly initializerKey = InitializerKey.Cipher;
@@ -39,6 +40,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
identity: Identity;
card: Card;
secureNote: SecureNote;
sshKey: SshKey;
attachments: Attachment[];
fields: Field[];
passwordHistory: Password[];
@@ -97,6 +99,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
this.identity = new Identity(obj.identity);
break;
case CipherType.SshKey:
this.sshKey = new SshKey(obj.sshKey);
break;
default:
break;
}
@@ -156,6 +161,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
model.identity = await this.identity.decrypt(this.organizationId, encKey);
break;
case CipherType.SshKey:
model.sshKey = await this.sshKey.decrypt(this.organizationId, encKey);
break;
default:
break;
}
@@ -240,6 +248,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.Identity:
c.identity = this.identity.toIdentityData();
break;
case CipherType.SshKey:
c.sshKey = this.sshKey.toSshKeyData();
break;
default:
break;
}
@@ -295,6 +306,9 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
case CipherType.SecureNote:
domain.secureNote = SecureNote.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
domain.sshKey = SshKey.fromJSON(obj.sshKey);
break;
default:
break;
}

View File

@@ -151,6 +151,7 @@ describe("Login DTO", () => {
password: "myPassword" as EncryptedString,
passwordRevisionDate: passwordRevisionDate.toISOString(),
totp: "myTotp" as EncryptedString,
// NOTE: `as any` is here until we migrate to Nx: https://bitwarden.atlassian.net/browse/PM-6493
fido2Credentials: [
{
credentialId: "keyId" as EncryptedString,
@@ -167,7 +168,7 @@ describe("Login DTO", () => {
discoverable: "discoverable" as EncryptedString,
creationDate: fido2CreationDate.toISOString(),
},
],
] as any,
});
expect(actual).toEqual({

View File

@@ -0,0 +1,67 @@
import { mockEnc } from "../../../../spec";
import { SshKeyApi } from "../api/ssh-key.api";
import { SshKeyData } from "../data/ssh-key.data";
import { SshKey } from "./ssh-key";
describe("Sshkey", () => {
let data: SshKeyData;
beforeEach(() => {
data = new SshKeyData(
new SshKeyApi({
PrivateKey: "privateKey",
PublicKey: "publicKey",
KeyFingerprint: "keyFingerprint",
}),
);
});
it("Convert", () => {
const sshKey = new SshKey(data);
expect(sshKey).toEqual({
privateKey: { encryptedString: "privateKey", encryptionType: 0 },
publicKey: { encryptedString: "publicKey", encryptionType: 0 },
keyFingerprint: { encryptedString: "keyFingerprint", encryptionType: 0 },
});
});
it("Convert from empty", () => {
const data = new SshKeyData();
const sshKey = new SshKey(data);
expect(sshKey).toEqual({
privateKey: null,
publicKey: null,
keyFingerprint: null,
});
});
it("toSshKeyData", () => {
const sshKey = new SshKey(data);
expect(sshKey.toSshKeyData()).toEqual(data);
});
it("Decrypt", async () => {
const sshKey = Object.assign(new SshKey(), {
privateKey: mockEnc("privateKey"),
publicKey: mockEnc("publicKey"),
keyFingerprint: mockEnc("keyFingerprint"),
});
const expectedView = {
privateKey: "privateKey",
publicKey: "publicKey",
keyFingerprint: "keyFingerprint",
};
const loginView = await sshKey.decrypt(null);
expect(loginView).toEqual(expectedView);
});
describe("fromJSON", () => {
it("returns null if object is null", () => {
expect(SshKey.fromJSON(null)).toBeNull();
});
});
});

View File

@@ -0,0 +1,70 @@
import { Jsonify } from "type-fest";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import Domain from "../../../platform/models/domain/domain-base";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { SshKeyData } from "../data/ssh-key.data";
import { SshKeyView } from "../view/ssh-key.view";
export class SshKey extends Domain {
privateKey: EncString;
publicKey: EncString;
keyFingerprint: EncString;
constructor(obj?: SshKeyData) {
super();
if (obj == null) {
return;
}
this.buildDomainModel(
this,
obj,
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
[],
);
}
decrypt(orgId: string, encKey?: SymmetricCryptoKey): Promise<SshKeyView> {
return this.decryptObj(
new SshKeyView(),
{
privateKey: null,
publicKey: null,
keyFingerprint: null,
},
orgId,
encKey,
);
}
toSshKeyData(): SshKeyData {
const c = new SshKeyData();
this.buildDataModel(this, c, {
privateKey: null,
publicKey: null,
keyFingerprint: null,
});
return c;
}
static fromJSON(obj: Partial<Jsonify<SshKey>>): SshKey {
if (obj == null) {
return null;
}
const privateKey = EncString.fromJSON(obj.privateKey);
const publicKey = EncString.fromJSON(obj.publicKey);
const keyFingerprint = EncString.fromJSON(obj.keyFingerprint);
return Object.assign(new SshKey(), obj, {
privateKey,
publicKey,
keyFingerprint,
});
}
}

View File

@@ -7,6 +7,7 @@ import { IdentityApi } from "../api/identity.api";
import { LoginUriApi } from "../api/login-uri.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { Cipher } from "../domain/cipher";
import { AttachmentRequest } from "./attachment.request";
@@ -23,6 +24,7 @@ export class CipherRequest {
secureNote: SecureNoteApi;
card: CardApi;
identity: IdentityApi;
sshKey: SshKeyApi;
fields: FieldApi[];
passwordHistory: PasswordHistoryRequest[];
// Deprecated, remove at some point and rename attachments2 to attachments
@@ -93,6 +95,17 @@ export class CipherRequest {
this.secureNote = new SecureNoteApi();
this.secureNote.type = cipher.secureNote.type;
break;
case CipherType.SshKey:
this.sshKey = new SshKeyApi();
this.sshKey.privateKey =
cipher.sshKey.privateKey != null ? cipher.sshKey.privateKey.encryptedString : null;
this.sshKey.publicKey =
cipher.sshKey.publicKey != null ? cipher.sshKey.publicKey.encryptedString : null;
this.sshKey.keyFingerprint =
cipher.sshKey.keyFingerprint != null
? cipher.sshKey.keyFingerprint.encryptedString
: null;
break;
case CipherType.Card:
this.card = new CardApi();
this.card.cardholderName =

View File

@@ -5,6 +5,7 @@ import { FieldApi } from "../api/field.api";
import { IdentityApi } from "../api/identity.api";
import { LoginApi } from "../api/login.api";
import { SecureNoteApi } from "../api/secure-note.api";
import { SshKeyApi } from "../api/ssh-key.api";
import { AttachmentResponse } from "./attachment.response";
import { PasswordHistoryResponse } from "./password-history.response";
@@ -21,6 +22,7 @@ export class CipherResponse extends BaseResponse {
card: CardApi;
identity: IdentityApi;
secureNote: SecureNoteApi;
sshKey: SshKeyApi;
favorite: boolean;
edit: boolean;
viewPassword: boolean;
@@ -75,6 +77,11 @@ export class CipherResponse extends BaseResponse {
this.secureNote = new SecureNoteApi(secureNote);
}
const sshKey = this.getResponseProperty("sshKey");
if (sshKey != null) {
this.sshKey = new SshKeyApi(sshKey);
}
const fields = this.getResponseProperty("Fields");
if (fields != null) {
this.fields = fields.map((f: any) => new FieldApi(f));

View File

@@ -14,6 +14,7 @@ import { IdentityView } from "./identity.view";
import { LoginView } from "./login.view";
import { PasswordHistoryView } from "./password-history.view";
import { SecureNoteView } from "./secure-note.view";
import { SshKeyView } from "./ssh-key.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
@@ -33,6 +34,7 @@ export class CipherView implements View, InitializerMetadata {
identity = new IdentityView();
card = new CardView();
secureNote = new SecureNoteView();
sshKey = new SshKeyView();
attachments: AttachmentView[] = null;
fields: FieldView[] = null;
passwordHistory: PasswordHistoryView[] = null;
@@ -74,6 +76,8 @@ export class CipherView implements View, InitializerMetadata {
return this.card;
case CipherType.Identity:
return this.identity;
case CipherType.SshKey:
return this.sshKey;
default:
break;
}
@@ -190,6 +194,9 @@ export class CipherView implements View, InitializerMetadata {
case CipherType.SecureNote:
view.secureNote = SecureNoteView.fromJSON(obj.secureNote);
break;
case CipherType.SshKey:
view.sshKey = SshKeyView.fromJSON(obj.sshKey);
break;
default:
break;
}

Some files were not shown because too many files have changed in this diff Show More