1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-09 03:53:53 +00:00

Merge branch 'main' into auth/pm-8111/browser-refresh-login-component

This commit is contained in:
Alec Rippberger
2024-09-24 21:58:22 -05:00
543 changed files with 31609 additions and 7636 deletions

View File

@@ -366,7 +366,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: ThemeStateService,
useClass: DefaultThemeStateService,
deps: [GlobalStateProvider],
deps: [GlobalStateProvider, ConfigService],
}),
safeProvider({
provide: AbstractThemingService,
@@ -451,6 +451,7 @@ const safeProviders: SafeProvider[] = [
fileUploadService: CipherFileUploadServiceAbstraction,
configService: ConfigService,
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
) =>
new CipherService(
cryptoService,
@@ -465,6 +466,7 @@ const safeProviders: SafeProvider[] = [
fileUploadService,
configService,
stateProvider,
accountService,
),
deps: [
CryptoServiceAbstraction,
@@ -479,6 +481,7 @@ const safeProviders: SafeProvider[] = [
CipherFileUploadServiceAbstraction,
ConfigService,
StateProvider,
AccountServiceAbstraction,
],
}),
safeProvider({
@@ -486,6 +489,7 @@ const safeProviders: SafeProvider[] = [
useClass: FolderService,
deps: [
CryptoServiceAbstraction,
EncryptService,
I18nServiceAbstraction,
CipherServiceAbstraction,
StateProvider,
@@ -529,7 +533,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: CollectionServiceAbstraction,
useClass: CollectionService,
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider],
deps: [CryptoServiceAbstraction, EncryptService, I18nServiceAbstraction, StateProvider],
}),
safeProvider({
provide: EnvironmentService,
@@ -787,6 +791,7 @@ const safeProviders: SafeProvider[] = [
I18nServiceAbstraction,
CollectionServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
PinServiceAbstraction,
AccountServiceAbstraction,
],
@@ -799,8 +804,10 @@ const safeProviders: SafeProvider[] = [
CipherServiceAbstraction,
PinServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
CryptoFunctionServiceAbstraction,
KdfConfigServiceAbstraction,
AccountServiceAbstraction,
],
}),
safeProvider({
@@ -811,6 +818,7 @@ const safeProviders: SafeProvider[] = [
ApiServiceAbstraction,
PinServiceAbstraction,
CryptoServiceAbstraction,
EncryptService,
CryptoFunctionServiceAbstraction,
CollectionServiceAbstraction,
KdfConfigServiceAbstraction,

View File

@@ -12,6 +12,7 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
import { OrganizationUserStatusType, PolicyType } from "@bitwarden/common/admin-console/enums";
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 { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -36,7 +37,6 @@ 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 { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
@@ -329,6 +329,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear);
}
// trim whitespace from the TOTP field
if (this.cipher.type === this.cipherType.Login && this.cipher.login.totp) {
this.cipher.login.totp = this.cipher.login.totp.trim();
}
if (this.cipher.name == null || this.cipher.name === "") {
this.platformUtilsService.showToast(
"error",

View File

@@ -6,6 +6,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -40,6 +41,7 @@ export class AttachmentsComponent implements OnInit {
protected cipherService: CipherService,
protected i18nService: I18nService,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected platformUtilsService: PlatformUtilsService,
protected apiService: ApiService,
protected win: Window,
@@ -178,7 +180,7 @@ export class AttachmentsComponent implements OnInit {
attachment.key != null
? attachment.key
: await this.cryptoService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,
@@ -249,7 +251,7 @@ export class AttachmentsComponent implements OnInit {
attachment.key != null
? attachment.key
: await this.cryptoService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);

View File

@@ -1,6 +1,9 @@
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { Validators, FormBuilder } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -29,6 +32,8 @@ export class FolderAddEditComponent implements OnInit {
constructor(
protected folderService: FolderService,
protected folderApiService: FolderApiServiceAbstraction,
protected accountService: AccountService,
protected cryptoService: CryptoService,
protected i18nService: I18nService,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
@@ -52,7 +57,9 @@ export class FolderAddEditComponent implements OnInit {
}
try {
const folder = await this.folderService.encrypt(this.folder);
const activeAccountId = await firstValueFrom(this.accountService.activeAccount$);
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeAccountId.id);
const folder = await this.folderService.encrypt(this.folder, userKey);
this.formPromise = this.folderApiService.save(folder);
await this.formPromise;
this.platformUtilsService.showToast(

View File

@@ -21,6 +21,7 @@ import { EventType } from "@bitwarden/common/enums";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -87,6 +88,7 @@ export class ViewComponent implements OnDestroy, OnInit {
protected tokenService: TokenService,
protected i18nService: I18nService,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected platformUtilsService: PlatformUtilsService,
protected auditService: AuditService,
protected win: Window,
@@ -442,7 +444,7 @@ export class ViewComponent implements OnDestroy, OnInit {
attachment.key != null
? attachment.key
: await this.cryptoService.getOrgKey(this.cipher.organizationId);
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, key);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
this.fileDownloadService.download({
fileName: attachment.fileName,
blobData: decBuf,

View File

@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, switchMap, takeUntil, tap } from "rxjs";
@@ -40,6 +40,7 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
private route: ActivatedRoute,
private i18nService: I18nService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private changeDetectorRef: ChangeDetectorRef,
) {}
ngOnInit(): void {
@@ -122,6 +123,10 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
if (data.showReadonlyHostname != null) {
this.showReadonlyHostname = data.showReadonlyHostname;
}
// Manually fire change detection to avoid ExpressionChangedAfterItHasBeenCheckedError
// when setting the page data from a service
this.changeDetectorRef.detectChanges();
}
private resetPageData() {

View File

@@ -1,8 +1,10 @@
<bit-simple-dialog>
<i bitDialogIcon class="bwi bwi-info-circle tw-text-3xl" aria-hidden="true"></i>
<span bitDialogTitle>{{ "yourAccountsFingerprint" | i18n }}:</span>
<span bitDialogTitle
><strong>{{ "yourAccountsFingerprint" | i18n }}:</strong></span
>
<span bitDialogContent>
<strong>{{ data.fingerprint.join("-") }}</strong>
{{ data.fingerprint.join("-") }}
</span>
<ng-container bitDialogFooter>
<a

View File

@@ -1,29 +1,47 @@
<div [formGroup]="form">
<bit-form-field>
<bit-label>{{ "vaultTimeout" | i18n }}</bit-label>
<div [formGroup]="form" class="tw-mb-4">
<bit-form-field [disableMargin]="!showCustom">
<bit-label>{{ "vaultTimeout1" | i18n }}</bit-label>
<bit-select formControlName="vaultTimeout">
<bit-option
*ngFor="let o of vaultTimeoutOptions"
*ngFor="let o of filteredVaultTimeoutOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
<bit-hint class="tw-text-sm">{{
((canLockVault$ | async) ? "vaultTimeoutDesc" : "vaultTimeoutLogoutDesc") | i18n
}}</bit-hint>
</bit-form-field>
<div class="tw-grid tw-grid-cols-12 tw-gap-4" *ngIf="showCustom" formGroupName="custom">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "customVaultTimeout" | i18n }}</bit-label>
<input bitInput type="number" min="0" formControlName="hours" />
<bit-hint>{{ "hours" | i18n }}</bit-hint>
<bit-form-field class="tw-col-span-6" disableMargin>
<input
bitInput
type="number"
min="0"
formControlName="hours"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "hours" | i18n }}</bit-label>
</bit-form-field>
<bit-form-field class="tw-col-span-6 tw-self-end">
<input bitInput type="number" min="0" name="minutes" formControlName="minutes" />
<bit-hint>{{ "minutes" | i18n }}</bit-hint>
<bit-form-field class="tw-col-span-6 tw-self-end" disableMargin>
<input
bitInput
type="number"
min="0"
name="minutes"
formControlName="minutes"
aria-labelledby="maximum-error"
/>
<bit-label>{{ "minutes" | i18n }}</bit-label>
</bit-form-field>
</div>
<small *ngIf="!exceedsMinimumTimout" class="tw-text-danger">
<bit-hint *ngIf="vaultTimeoutPolicy != null && !exceedsMaximumTimeout">
{{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }}
</bit-hint>
<small *ngIf="!exceedsMinimumTimeout" class="tw-text-danger">
<i class="bwi bwi-error" aria-hidden="true"></i> {{ "vaultCustomTimeoutMinimum" | i18n }}
</small>
<small class="tw-text-danger" *ngIf="exceedsMaximumTimeout" id="maximum-error">
<i class="bwi bwi-error" aria-hidden="true"></i>
{{
"vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes
}}
</small>
</div>

View File

@@ -55,16 +55,41 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"];
export class VaultTimeoutInputComponent
implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges
{
protected readonly VaultTimeoutAction = VaultTimeoutAction;
get showCustom() {
return this.form.get("vaultTimeout").value === VaultTimeoutInputComponent.CUSTOM_VALUE;
}
get exceedsMinimumTimout(): boolean {
get exceedsMinimumTimeout(): boolean {
return (
!this.showCustom || this.customTimeInMinutes() > VaultTimeoutInputComponent.MIN_CUSTOM_MINUTES
);
}
get exceedsMaximumTimeout(): boolean {
return (
this.showCustom &&
this.customTimeInMinutes() >
this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours
);
}
get filteredVaultTimeoutOptions(): VaultTimeoutOption[] {
// by policy max value
if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) {
return this.vaultTimeoutOptions;
}
return this.vaultTimeoutOptions.filter((option) => {
if (typeof option.value === "number") {
return option.value <= this.vaultTimeoutPolicy.data.minutes;
}
return false;
});
}
static CUSTOM_VALUE = -100;
static MIN_CUSTOM_MINUTES = 0;
@@ -77,6 +102,7 @@ export class VaultTimeoutInputComponent
});
@Input() vaultTimeoutOptions: VaultTimeoutOption[];
vaultTimeoutPolicy: Policy;
vaultTimeoutPolicyHours: number;
vaultTimeoutPolicyMinutes: number;
@@ -207,7 +233,7 @@ export class VaultTimeoutInputComponent
return { policyError: true };
}
if (!this.exceedsMinimumTimout) {
if (!this.exceedsMinimumTimeout) {
return { minTimeoutError: true };
}

View File

@@ -12,6 +12,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -37,6 +38,7 @@ describe("AuthRequestLoginStrategy", () => {
let cache: AuthRequestLoginStrategyData;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
@@ -101,6 +103,7 @@ describe("AuthRequestLoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,

View File

@@ -22,6 +22,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -104,6 +105,7 @@ describe("LoginStrategy", () => {
let loginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
@@ -128,6 +130,7 @@ describe("LoginStrategy", () => {
loginStrategyService = mock<LoginStrategyServiceAbstraction>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
@@ -156,6 +159,7 @@ describe("LoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,
@@ -467,6 +471,7 @@ describe("LoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,

View File

@@ -26,6 +26,7 @@ import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -66,6 +67,7 @@ export abstract class LoginStrategy {
protected accountService: AccountService,
protected masterPasswordService: InternalMasterPasswordServiceAbstraction,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected apiService: ApiService,
protected tokenService: TokenService,
protected appIdService: AppIdService,

View File

@@ -16,6 +16,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -63,6 +64,7 @@ describe("PasswordLoginStrategy", () => {
let loginStrategyService: MockProxy<LoginStrategyServiceAbstraction>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
@@ -88,6 +90,7 @@ describe("PasswordLoginStrategy", () => {
loginStrategyService = mock<LoginStrategyServiceAbstraction>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
@@ -127,6 +130,7 @@ describe("PasswordLoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,

View File

@@ -17,6 +17,7 @@ import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -44,6 +45,7 @@ describe("SsoLoginStrategy", () => {
let masterPasswordService: FakeMasterPasswordService;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
@@ -78,6 +80,7 @@ describe("SsoLoginStrategy", () => {
masterPasswordService = new FakeMasterPasswordService();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
@@ -125,6 +128,7 @@ describe("SsoLoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,

View File

@@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import {
Environment,
EnvironmentService,
@@ -39,6 +40,7 @@ describe("UserApiLoginStrategy", () => {
let masterPasswordService: FakeMasterPasswordService;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let apiService: MockProxy<ApiService>;
let tokenService: MockProxy<TokenService>;
let appIdService: MockProxy<AppIdService>;
@@ -99,6 +101,7 @@ describe("UserApiLoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,

View File

@@ -14,6 +14,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.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";
@@ -37,6 +38,7 @@ describe("WebAuthnLoginStrategy", () => {
let masterPasswordService: FakeMasterPasswordService;
let cryptoService!: MockProxy<CryptoService>;
let encryptService!: MockProxy<EncryptService>;
let apiService!: MockProxy<ApiService>;
let tokenService!: MockProxy<TokenService>;
let appIdService!: MockProxy<AppIdService>;
@@ -79,6 +81,7 @@ describe("WebAuthnLoginStrategy", () => {
masterPasswordService = new FakeMasterPasswordService();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
apiService = mock<ApiService>();
tokenService = mock<TokenService>();
appIdService = mock<AppIdService>();
@@ -103,6 +106,7 @@ describe("WebAuthnLoginStrategy", () => {
accountService,
masterPasswordService,
cryptoService,
encryptService,
apiService,
tokenService,
appIdService,
@@ -221,7 +225,7 @@ describe("WebAuthnLoginStrategy", () => {
const mockUserKeyArray: Uint8Array = randomBytes(32);
const mockUserKey = new SymmetricCryptoKey(mockUserKeyArray) as UserKey;
cryptoService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
encryptService.decryptToBytes.mockResolvedValue(mockPrfPrivateKey);
cryptoService.rsaDecrypt.mockResolvedValue(mockUserKeyArray);
// Act
@@ -235,8 +239,8 @@ describe("WebAuthnLoginStrategy", () => {
userId,
);
expect(cryptoService.decryptToBytes).toHaveBeenCalledTimes(1);
expect(cryptoService.decryptToBytes).toHaveBeenCalledWith(
expect(encryptService.decryptToBytes).toHaveBeenCalledTimes(1);
expect(encryptService.decryptToBytes).toHaveBeenCalledWith(
idTokenResponse.userDecryptionOptions.webAuthnPrfOption.encryptedPrivateKey,
webAuthnCredentials.prfKey,
);
@@ -268,7 +272,7 @@ describe("WebAuthnLoginStrategy", () => {
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
// Assert
expect(cryptoService.decryptToBytes).not.toHaveBeenCalled();
expect(encryptService.decryptToBytes).not.toHaveBeenCalled();
expect(cryptoService.rsaDecrypt).not.toHaveBeenCalled();
expect(cryptoService.setUserKey).not.toHaveBeenCalled();
});
@@ -303,7 +307,7 @@ describe("WebAuthnLoginStrategy", () => {
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
cryptoService.decryptToBytes.mockResolvedValue(null);
encryptService.decryptToBytes.mockResolvedValue(null);
// Act
await webAuthnLoginStrategy.logIn(webAuthnCredentials);

View File

@@ -80,7 +80,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
}
// decrypt prf encrypted private key
const privateKey = await this.cryptoService.decryptToBytes(
const privateKey = await this.encryptService.decryptToBytes(
webAuthnPrfOption.encryptedPrivateKey,
credentials.prfKey,
);

View File

@@ -0,0 +1,49 @@
import { combineLatest, firstValueFrom, map } from "rxjs";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
export abstract class LockService {
/**
* Locks all accounts.
*/
abstract lockAll(): Promise<void>;
}
export class DefaultLockService implements LockService {
constructor(
private readonly accountService: AccountService,
private readonly vaultTimeoutService: VaultTimeoutService,
) {}
async lockAll() {
const accounts = await firstValueFrom(
combineLatest([this.accountService.activeAccount$, this.accountService.accounts$]).pipe(
map(([activeAccount, accounts]) => {
const otherAccounts = Object.keys(accounts) as UserId[];
if (activeAccount == null) {
return { activeAccount: null, otherAccounts: otherAccounts };
}
return {
activeAccount: activeAccount.id,
otherAccounts: otherAccounts.filter((accountId) => accountId !== activeAccount.id),
};
}),
),
);
for (const otherAccount of accounts.otherAccounts) {
await this.vaultTimeoutService.lock(otherAccount);
}
// Do the active account last in case we ever try to route the user on lock
// that way this whole operation will be complete before that routing
// could take place.
if (accounts.activeAccount != null) {
await this.vaultTimeoutService.lock(accounts.activeAccount);
}
}
}

View File

@@ -0,0 +1,43 @@
import { mock } from "jest-mock-extended";
import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout.service";
import { mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { DefaultLockService } from "./lock.service";
describe("DefaultLockService", () => {
const mockUser1 = "user1" as UserId;
const mockUser2 = "user2" as UserId;
const mockUser3 = "user3" as UserId;
const accountService = mockAccountServiceWith(mockUser1);
const vaultTimeoutService = mock<VaultTimeoutService>();
const sut = new DefaultLockService(accountService, vaultTimeoutService);
describe("lockAll", () => {
it("locks the active account last", async () => {
await accountService.addAccount(mockUser2, {
name: "name2",
email: "email2@example.com",
emailVerified: false,
});
await accountService.addAccount(mockUser3, {
name: "name3",
email: "email3@example.com",
emailVerified: false,
});
await sut.lockAll();
expect(vaultTimeoutService.lock).toHaveBeenCalledTimes(3);
// Non-Active users should be called first
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(1, mockUser2);
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(2, mockUser3);
// Active user should be called last
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(3, mockUser1);
});
});
});

View File

@@ -4,3 +4,4 @@ export * from "./login-strategies/login-strategy.service";
export * from "./user-decryption-options/user-decryption-options.service";
export * from "./auth-request/auth-request.service";
export * from "./register-route.service";
export * from "./accounts/lock.service";

View File

@@ -317,6 +317,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.encryptService,
this.apiService,
this.tokenService,
this.appIdService,

View File

@@ -7,8 +7,6 @@ export class PermissionsApi extends BaseResponse {
createNewCollections: boolean;
editAnyCollection: boolean;
deleteAnyCollection: boolean;
editAssignedCollections: boolean;
deleteAssignedCollections: boolean;
manageCiphers: boolean;
manageGroups: boolean;
manageSso: boolean;
@@ -29,8 +27,6 @@ export class PermissionsApi extends BaseResponse {
this.createNewCollections = this.getResponseProperty("CreateNewCollections");
this.editAnyCollection = this.getResponseProperty("EditAnyCollection");
this.deleteAnyCollection = this.getResponseProperty("DeleteAnyCollection");
this.editAssignedCollections = this.getResponseProperty("EditAssignedCollections");
this.deleteAssignedCollections = this.getResponseProperty("DeleteAssignedCollections");
this.manageCiphers = this.getResponseProperty("ManageCiphers");
this.manageGroups = this.getResponseProperty("ManageGroups");

View File

@@ -2,7 +2,6 @@ import { firstValueFrom, map, Observable } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { AppIdService } from "../../platform/abstractions/app-id.service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
@@ -334,9 +333,6 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
async recordDeviceTrustLoss(): Promise<void> {
if (!(await this.configService.getFeatureFlag(FeatureFlag.DeviceTrustLogging))) {
return;
}
const deviceIdentifier = await this.appIdService.getAppId();
await this.devicesApiService.postDeviceTrustLoss(deviceIdentifier);
}

View File

@@ -340,8 +340,6 @@ describe("KeyConnectorService", () => {
createNewCollections: false,
editAnyCollection: false,
deleteAnyCollection: false,
editAssignedCollections: false,
deleteAssignedCollections: false,
manageGroups: false,
managePolicies: false,
manageSso: false,

View File

@@ -107,3 +107,7 @@ export const ExtensionCommand = {
export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof ExtensionCommand];
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute
export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;
export * from "./match-patterns";

View File

@@ -0,0 +1,26 @@
export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
// `CardExpiryDateDelimiters` is not intended solely for regex consumption,
// so we need to format it here
export const ExpiryDateDelimitersPattern =
"\\" +
CardExpiryDateDelimiters.join("\\")
// replace space character with the regex whitespace character class
.replace(" ", "s");
export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";
// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024)
export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}";
export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g");
export const IrrelevantExpiryCharactersPatternExpression = new RegExp(
// "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\d${ExpiryDateDelimitersPattern}]`,
"g",
);
export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`);
export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`);

View File

@@ -0,0 +1,284 @@
import {
normalizeExpiryYearFormat,
isCardExpired,
parseYearMonthExpiry,
} from "@bitwarden/common/autofill/utils";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
function getExpiryYearValueFormats(currentCentury: string) {
return [
[-12, `${currentCentury}12`],
[0, `${currentCentury}00`],
[2043, "2043"], // valid year with a length of four should be taken directly
[24, `${currentCentury}24`],
[3054, "3054"], // valid year with a length of four should be taken directly
[31423524543, `${currentCentury}43`],
[4, `${currentCentury}04`],
[null, null],
[undefined, null],
["-12", `${currentCentury}12`],
["", null],
["0", `${currentCentury}00`],
["00", `${currentCentury}00`],
["000", `${currentCentury}00`],
["0000", `${currentCentury}00`],
["00000", `${currentCentury}00`],
["0234234", `${currentCentury}34`],
["04", `${currentCentury}04`],
["2043", "2043"], // valid year with a length of four should be taken directly
["24", `${currentCentury}24`],
["3054", "3054"], // valid year with a length of four should be taken directly
["31423524543", `${currentCentury}43`],
["4", `${currentCentury}04`],
["aaaa", null],
["adgshsfhjsdrtyhsrth", null],
["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`],
];
}
describe("normalizeExpiryYearFormat", () => {
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
const formattedValue = normalizeExpiryYearFormat(inputValue);
expect(formattedValue).toEqual(expectedValue);
});
});
describe("in the year 3107", () => {
const theDistantFuture = new Date(Date.UTC(3107, 1, 1));
jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf());
beforeAll(() => {
jest.useFakeTimers({ advanceTimers: true });
jest.setSystemTime(theDistantFuture);
});
afterAll(() => {
jest.useRealTimers();
});
const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2);
expect(currentCentury).toBe("31");
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
const formattedValue = normalizeExpiryYearFormat(inputValue);
expect(formattedValue).toEqual(expectedValue);
});
});
jest.clearAllTimers();
});
});
function getCardExpiryDateValues() {
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
const currentMonth = currentDate.getMonth() + 1;
return [
[null, null, false], // no month, no year
[undefined, undefined, false], // no month, no year, invalid values
["", "", false], // no month, no year, invalid values
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
["0", `${currentYear}`, true], // invalid month
["0", `${currentYear - 1}`, true], // invalid 0 month
["00", `${currentYear + 1}`, false], // invalid 0 month
[`${currentMonth}`, "0000", true], // current month, in the year 2000
[null, `${currentYear}`.slice(-2), false], // no month, this year
[null, `${currentYear - 1}`.slice(-2), true], // no month, last year
["1", null, false], // no year, January
["1", `${currentYear - 1}`, true], // January last year
["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed)
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
];
}
describe("isCardExpired", () => {
const expiryYearValueFormats = getCardExpiryDateValues();
expiryYearValueFormats.forEach(
([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => {
it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => {
const testCardView = new CardView();
testCardView.expMonth = inputMonth;
testCardView.expYear = inputYear;
const cardIsExpired = isCardExpired(testCardView);
expect(cardIsExpired).toBe(expectedValue);
});
},
);
});
const combinedDateTestValues = [
" 2024 / 05 ",
"05 2024",
"05 2024", // Tab whitespace character
"052024", // Em Quad
"052024", // Em Space
"05 2024", // En Quad
"052024", // En Space
"052024", // Figure Space
"052024", // Four-Per-Em Space
"052024", // Hair Space
"05 2024", // Ideographic Space
"052024", // Medium Mathematical Space
"05 2024", // No-Break Space
"052024", // ogham space mark
"052024", // Punctuation Space
"052024", // Six-Per-Em Space
"052024", // Thin Space
"052024", // Three-Per-Em Space
"05 24",
"05-2024",
"05-24",
"05.2024",
"05.24",
"05/2024",
"05/24",
"052024",
"0524",
"2024 05",
"2024 5",
"2024-05",
"2024-5",
"2024.05",
"2024.5",
"2024/05",
"2024/5",
"202405",
"20245",
"24 05",
"24 5",
"24-05",
"24-5",
"24.05",
"24.5",
"24/05",
"24/5",
"2405",
"5 2024",
"5 24",
"5-2024",
"5-24",
"5.2024",
"5.24",
"5/2024",
"5/24",
"52024",
];
const expectedParsedValue = ["2024", "5"];
describe("parseYearMonthExpiry", () => {
it('returns "null" expiration year and month values when a value of "" is passed', () => {
expect(parseYearMonthExpiry("")).toStrictEqual([null, null]);
});
it('returns "null" expiration year and month values when a value of "/" is passed', () => {
expect(parseYearMonthExpiry("/")).toStrictEqual([null, null]);
});
combinedDateTestValues.forEach((combinedDate) => {
it(`returns an expiration year value of "${expectedParsedValue[0]}" and month value of "${expectedParsedValue[1]}" when a value of "${combinedDate}" is passed`, () => {
expect(parseYearMonthExpiry(combinedDate)).toStrictEqual(expectedParsedValue);
});
});
it('returns an expiration year value of "2002" and month value of "2" when a value of "022" is passed', () => {
expect(parseYearMonthExpiry("022")).toStrictEqual(["2002", "2"]);
});
it('returns an expiration year value of "2002" and month value of "2" when a value of "202" is passed', () => {
expect(parseYearMonthExpiry("202")).toStrictEqual(["2002", "2"]);
});
it('returns an expiration year value of "2002" and month value of "1" when a value of "1/2/3/4" is passed', () => {
expect(parseYearMonthExpiry("1/2/3/4")).toStrictEqual(["2002", "1"]);
});
it('returns valid expiration year and month values when a value of "198" is passed', () => {
// This static value will cause the test to fail in 2098
const testValue = "198";
const parsedValue = parseYearMonthExpiry(testValue);
expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
expect(parsedValue).toStrictEqual(["2098", "1"]);
});
// Ambiguous input cases: we use try/catch for these cases as a workaround to accept either
// outcome (both are valid interpretations) in the event of any future code changes.
describe("ambiguous input cases", () => {
it('returns valid expiration year and month values when a value of "111" is passed', () => {
const testValue = "111";
const parsedValue = parseYearMonthExpiry(testValue);
expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
try {
expect(parsedValue).toStrictEqual(["2011", "1"]);
} catch {
expect(parsedValue).toStrictEqual(["2001", "11"]);
}
});
it('returns valid expiration year and month values when a value of "212" is passed', () => {
const testValue = "212";
const parsedValue = parseYearMonthExpiry(testValue);
expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
try {
expect(parsedValue).toStrictEqual(["2012", "2"]);
} catch {
expect(parsedValue).toStrictEqual(["2021", "2"]);
}
});
it('returns valid expiration year and month values when a value of "245" is passed', () => {
const testValue = "245";
const parsedValue = parseYearMonthExpiry(testValue);
expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
try {
expect(parsedValue).toStrictEqual(["2045", "2"]);
} catch {
expect(parsedValue).toStrictEqual(["2024", "5"]);
}
});
it('returns valid expiration year and month values when a value of "524" is passed', () => {
const testValue = "524";
const parsedValue = parseYearMonthExpiry(testValue);
expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);
try {
expect(parsedValue).toStrictEqual(["2024", "5"]);
} catch {
expect(parsedValue).toStrictEqual(["2052", "4"]);
}
});
});
});

View File

@@ -0,0 +1,307 @@
import {
DelimiterPatternExpression,
ExpiryFullYearPattern,
ExpiryFullYearPatternExpression,
IrrelevantExpiryCharactersPatternExpression,
MonthPatternExpression,
} from "@bitwarden/common/autofill/constants";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
/**
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
*
* @param {(string | number)} yearInput
* @return {*} {(Year | null)}
*/
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
// The input[type="number"] is returning a number, convert it to a string
// An empty field returns null, avoid casting `"null"` to a string
const yearInputIsEmpty = yearInput == null || yearInput === "";
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
// Exit early if year is already formatted correctly or empty
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
return expirationYear as Year;
}
expirationYear = expirationYear
// For safety, because even input[type="number"] will allow decimals
.replace(/[^\d]/g, "")
// remove any leading zero padding (leave the last leading zero if it ends the string)
.replace(/^[0]+(?=.)/, "");
if (expirationYear === "") {
expirationYear = null;
}
// given the context of payment card expiry, a year character length of 3, or over 4
// is more likely to be a mistake than an intentional value for the far past or far future.
if (expirationYear && expirationYear.length !== 4) {
const paddedYear = ("00" + expirationYear).slice(-2);
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
expirationYear = currentCentury + paddedYear;
}
return expirationYear as Year | null;
}
/**
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
* the card is expired.
*
* @param {CardView} cipherCard
* @return {*} {boolean}
*/
export function isCardExpired(cipherCard: CardView): boolean {
if (cipherCard) {
const { expMonth = null, expYear = null } = cipherCard;
const now = new Date();
const normalizedYear = normalizeExpiryYearFormat(expYear);
// If the card year is before the current year, don't bother checking the month
if (normalizedYear && parseInt(normalizedYear, 10) < now.getFullYear()) {
return true;
}
if (normalizedYear && expMonth) {
const parsedMonthInteger = parseInt(expMonth, 10);
const parsedMonth = isNaN(parsedMonthInteger)
? 0
: // Add a month floor of 0 to protect against an invalid low month value of "0" or negative integers
Math.max(
// `Date` months are zero-indexed
parsedMonthInteger - 1,
0,
);
const parsedYear = parseInt(normalizedYear, 10);
// First day of the next month minus one, to get last day of the card month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
return cardExpiry < now;
}
}
return false;
}
/**
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
*
* @param {string} combinedExpiryValue
* @return {*} {string[]}
*/
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
let sanitizedValue = combinedExpiryValue
.replace(IrrelevantExpiryCharactersPatternExpression, "")
.trim();
// Do this after initial value replace to avoid identifying leading whitespace as delimiter
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
let dateParts = [sanitizedValue];
if (parsedDelimiter?.length) {
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
sanitizedValue = sanitizedValue
// Remove all other delimiter characters not identified as the delimiter
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
// Also de-dupe the delimiter character
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
dateParts = sanitizedValue.split(parsedDelimiter);
}
return (
dateParts
// remove values that have no length
.filter((splitValue) => splitValue?.length)
);
}
/**
* Given an array of split card expiry date parts,
* returns an array of those values ordered by year then month
*
* @param {string[]} splitDateInput
* @return {*} {([string | null, string | null])}
*/
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
// from more authoritative checks to checks yielding increasingly inferred conclusions
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// Values are too ambiguous (e.g. "12/09"). For the most part,
// a month-looking value likely is, at the time of writing (year 2024).
let parsedYear = firstPart;
let parsedMonth = secondPart;
if (MonthPatternExpression.test(firstPart)) {
parsedYear = secondPart;
parsedMonth = firstPart;
}
return [parsedYear, parsedMonth];
}
/**
* Given a single string of integers, attempts to identify card expiry date portions within
* and return values ordered by year then month
*
* @param {string} dateInput
* @return {*} {([string | null, string | null])}
*/
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
if (dateInput.length > 4) {
// e.g.
// "052024"
// "202405"
// "20245"
// "52024"
// If the value is over 5-characters long, it likely has a full year format in it
const [parsedYear, parsedMonth] = dateInput
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
return [parsedYear, parsedMonth];
}
if (dateInput.length === 4) {
// e.g.
// "0524"
// "2405"
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
// neither a year or month will be represented with three characters
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
const splitFirstPartSecondHalf = dateInput.slice(-2);
let parsedYear = splitFirstPartSecondHalf;
let parsedMonth = splitFirstPartFirstHalf;
// If the first part doesn't match a month pattern, assume it's a year
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
parsedYear = splitFirstPartFirstHalf;
parsedMonth = splitFirstPartSecondHalf;
}
return [parsedYear, parsedMonth];
}
// e.g.
// "245"
// "202"
// "212"
// "022"
// "111"
// A valid year representation here must be two characters so try to find it first.
let parsedYear = null;
let parsedMonth = null;
// Split if there is a digit with a leading zero
const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
if (splitFirstPartOnLeadingZero.length > 1) {
parsedYear = splitFirstPartOnLeadingZero[0];
parsedMonth = splitFirstPartOnLeadingZero[1];
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
parsedMonth = splitFirstPartOnLeadingZero[0];
parsedYear = splitFirstPartOnLeadingZero[1];
}
} else {
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
parsedYear = dateInput.slice(0, 2);
parsedMonth = dateInput.slice(-1);
const currentYear = new Date().getFullYear();
const normalizedParsedYear = parseInt(normalizeExpiryYearFormat(parsedYear), 10);
const normalizedParsedYearAlternative = parseInt(
normalizeExpiryYearFormat(dateInput.slice(-2)),
10,
);
if (normalizedParsedYear < currentYear && normalizedParsedYearAlternative >= currentYear) {
parsedYear = dateInput.slice(-2);
parsedMonth = dateInput.slice(0, 1);
}
}
return [parsedYear, parsedMonth];
}
/**
* Attempt to parse year and month parts of a combined expiry date value.
*
* @param {string} combinedExpiryValue
* @return {*} {([string | null, string | null])}
*/
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
let parsedYear = null;
let parsedMonth = null;
const dateParts = splitCombinedDateValues(combinedExpiryValue);
if (dateParts.length < 1) {
return [null, null];
}
const sanitizedFirstPart =
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
const sanitizedSecondPart =
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
// If there is only one date part, no delimiter was found in the passed value
if (dateParts.length === 1) {
[parsedYear, parsedMonth] = parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
}
// There are multiple date parts
else {
[parsedYear, parsedMonth] = parseDelimitedYearMonthExpiry([
sanitizedFirstPart,
sanitizedSecondPart,
]);
}
const normalizedParsedYear = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
// Set "empty" values to null
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
return [parsedYear, parsedMonth];
}

View File

@@ -24,8 +24,6 @@ export enum FeatureFlag {
VaultBulkManagementAction = "vault-bulk-management-action",
AC2828_ProviderPortalMembersPage = "AC-2828_provider-portal-members-page",
IdpAutoSubmitLogin = "idp-auto-submit-login",
DeviceTrustLogging = "pm-8285-device-trust-logging",
AuthenticatorTwoFactorToken = "authenticator-2fa-token",
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
@@ -34,7 +32,6 @@ export enum FeatureFlag {
AccountDeprovisioning = "pm-10308-account-deprovisioning",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
AC2476_DeprecateStripeSourcesAPI = "AC-2476-deprecate-stripe-sources-api",
StorageReseedRefactor = "storage-reseed-refactor",
CipherKeyEncryption = "cipher-key-encryption",
}
@@ -56,7 +53,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.EnableConsolidatedBilling]: FALSE,
[FeatureFlag.AC1795_UpdatedSubscriptionStatusSection]: FALSE,
[FeatureFlag.EnableDeleteProvider]: FALSE,
[FeatureFlag.ExtensionRefresh]: true,
[FeatureFlag.ExtensionRefresh]: FALSE,
[FeatureFlag.PersistPopupView]: FALSE,
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
@@ -70,14 +67,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.AC2828_ProviderPortalMembersPage]: FALSE,
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
[FeatureFlag.DeviceTrustLogging]: FALSE,
[FeatureFlag.AuthenticatorTwoFactorToken]: FALSE,
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: true,
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.StorageReseedRefactor]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
[FeatureFlag.AC2476_DeprecateStripeSourcesAPI]: FALSE,

View File

@@ -15,7 +15,6 @@ import {
UserPublicKey,
} from "../../types/key";
import { KeySuffixOptions, HashPurpose } from "../enums";
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
import { EncString } from "../models/domain/enc-string";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
@@ -373,37 +372,6 @@ export abstract class CryptoService {
* @param userId The desired user
*/
abstract clearDeprecatedKeys(keySuffix: KeySuffixOptions, userId?: string): Promise<void>;
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.encrypt
*/
abstract encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise<EncString>;
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.encryptToBytes
*/
abstract encryptToBytes(
plainValue: Uint8Array,
key?: SymmetricCryptoKey,
): Promise<EncArrayBuffer>;
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToBytes
*/
abstract decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<Uint8Array>;
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToUtf8
*/
abstract decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string>;
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToBytes
*/
abstract decryptFromBytes(
encBuffer: EncArrayBuffer,
key: SymmetricCryptoKey,
): Promise<Uint8Array>;
/**
* Retrieves all the keys needed for decrypting Ciphers

View File

@@ -48,7 +48,6 @@ import { StateService } from "../abstractions/state.service";
import { KeySuffixOptions, HashPurpose, EncryptionType } from "../enums";
import { convertValues } from "../misc/convert-values";
import { EFFLongWordList } from "../misc/wordlist";
import { EncArrayBuffer } from "../models/domain/enc-array-buffer";
import { EncString, EncryptedString } from "../models/domain/enc-string";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { ActiveUserState, StateProvider } from "../state";
@@ -859,58 +858,6 @@ export class CryptoService implements CryptoServiceAbstraction {
}
}
// --DEPRECATED METHODS--
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.encrypt
*/
async encrypt(plainValue: string | Uint8Array, key?: SymmetricCryptoKey): Promise<EncString> {
key ||= await this.getUserKeyWithLegacySupport();
return await this.encryptService.encrypt(plainValue, key);
}
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.encryptToBytes
*/
async encryptToBytes(plainValue: Uint8Array, key?: SymmetricCryptoKey): Promise<EncArrayBuffer> {
key ||= await this.getUserKeyWithLegacySupport();
return this.encryptService.encryptToBytes(plainValue, key);
}
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToBytes
*/
async decryptToBytes(encString: EncString, key?: SymmetricCryptoKey): Promise<Uint8Array> {
key ||= await this.getUserKeyWithLegacySupport();
return this.encryptService.decryptToBytes(encString, key);
}
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToUtf8
*/
async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise<string> {
key ||= await this.getUserKeyWithLegacySupport();
return await this.encryptService.decryptToUtf8(encString, key);
}
/**
* @deprecated July 25 2022: Get the key you need from CryptoService (getKeyForUserEncryption or getOrgKey)
* and then call encryptService.decryptToBytes
*/
async decryptFromBytes(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (encBuffer == null) {
throw new Error("No buffer provided for decryption.");
}
key ||= await this.getUserKeyWithLegacySupport();
return this.encryptService.decryptToBytes(encBuffer, key);
}
userKey$(userId: UserId): Observable<UserKey> {
return this.stateProvider.getUser(userId, USER_KEY).state$;
}

View File

@@ -1,7 +1,7 @@
import { TextEncoder } from "util";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, of } from "rxjs";
import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service";
import { UserId } from "../../../types/guid";
@@ -53,7 +53,9 @@ describe("FidoAuthenticatorService", () => {
userInterface = mock<Fido2UserInterfaceService>();
userInterfaceSession = mock<Fido2UserInterfaceSession>();
userInterface.newSession.mockResolvedValue(userInterfaceSession);
syncService = mock<SyncService>();
syncService = mock<SyncService>({
activeUserLastSync$: () => of(new Date()),
});
accountService = mock<AccountService>();
authenticator = new Fido2AuthenticatorService(
cipherService,

View File

@@ -94,7 +94,14 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
}
await userInterfaceSession.ensureUnlockedVault();
await this.syncService.fullSync(false);
// Avoid syncing if we did it reasonably soon as the only reason for syncing is to validate excludeCredentials
const lastSync = await firstValueFrom(this.syncService.activeUserLastSync$());
const threshold = new Date().getTime() - 1000 * 60 * 30; // 30 minutes ago
if (!lastSync || lastSync.getTime() < threshold) {
await this.syncService.fullSync(false);
}
const existingCipherIds = await this.findExcludedCredentials(
params.excludeCredentialDescriptorList,
@@ -223,15 +230,17 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
let cipherOptions: CipherView[];
await userInterfaceSession.ensureUnlockedVault();
await this.syncService.fullSync(false);
if (params.allowCredentialDescriptorList?.length > 0) {
cipherOptions = await this.findCredentialsById(
params.allowCredentialDescriptorList,
params.rpId,
);
} else {
cipherOptions = await this.findCredentialsByRp(params.rpId);
// Try to find the passkey locally before causing a sync to speed things up
// only skip syncing if we found credentials AND all of them have a counter = 0
cipherOptions = await this.findCredential(params, cipherOptions);
if (
cipherOptions.length === 0 ||
cipherOptions.some((c) => c.login.fido2Credentials.some((p) => p.counter > 0))
) {
// If no passkey is found, or any had a non-zero counter, sync to get the latest data
await this.syncService.fullSync(false);
cipherOptions = await this.findCredential(params, cipherOptions);
}
if (cipherOptions.length === 0) {
@@ -335,6 +344,21 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr
}
}
private async findCredential(
params: Fido2AuthenticatorGetAssertionParams,
cipherOptions: CipherView[],
) {
if (params.allowCredentialDescriptorList?.length > 0) {
cipherOptions = await this.findCredentialsById(
params.allowCredentialDescriptorList,
params.rpId,
);
} else {
cipherOptions = await this.findCredentialsByRp(params.rpId);
}
return cipherOptions;
}
private requiresUserVerificationPrompt(
params: Fido2AuthenticatorGetAssertionParams,
cipherOptions: CipherView[],

View File

@@ -17,8 +17,10 @@ export class Fido2Utils {
return Fido2Utils.fromB64ToArray(Fido2Utils.fromUrlB64ToB64(str));
}
static bufferSourceToUint8Array(bufferSource: BufferSource) {
if (Fido2Utils.isArrayBuffer(bufferSource)) {
static bufferSourceToUint8Array(bufferSource: BufferSource): Uint8Array {
if (bufferSource instanceof Uint8Array) {
return bufferSource;
} else if (Fido2Utils.isArrayBuffer(bufferSource)) {
return new Uint8Array(bufferSource);
} else {
return new Uint8Array(bufferSource.buffer);

View File

@@ -2,7 +2,7 @@ import { GlobalState } from "./global-state";
import { KeyDefinition } from "./key-definition";
/**
* A provider for geting an implementation of global state scoped to the given key.
* A provider for getting an implementation of global state scoped to the given key.
*/
export abstract class GlobalStateProvider {
/**

View File

@@ -1,5 +1,7 @@
import { Observable, map } from "rxjs";
import { Observable, combineLatest, map } from "rxjs";
import { FeatureFlag } from "../../enums/feature-flag.enum";
import { ConfigService } from "../abstractions/config/config.service";
import { ThemeType } from "../enums";
import { GlobalStateProvider, KeyDefinition, THEMING_DISK } from "../state";
@@ -16,17 +18,32 @@ export abstract class ThemeStateService {
abstract setSelectedTheme(theme: ThemeType): Promise<void>;
}
const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
export const THEME_SELECTION = new KeyDefinition<ThemeType>(THEMING_DISK, "selection", {
deserializer: (s) => s,
});
export class DefaultThemeStateService implements ThemeStateService {
private readonly selectedThemeState = this.globalStateProvider.get(THEME_SELECTION);
selectedTheme$ = this.selectedThemeState.state$.pipe(map((theme) => theme ?? this.defaultTheme));
selectedTheme$ = combineLatest([
this.selectedThemeState.state$,
this.configService.getFeatureFlag$(FeatureFlag.ExtensionRefresh),
]).pipe(
map(([theme, isExtensionRefresh]) => {
// The extension refresh should not allow for Nord or SolarizedDark
// Default the user to their system theme
if (isExtensionRefresh && [ThemeType.Nord, ThemeType.SolarizedDark].includes(theme)) {
return ThemeType.System;
}
return theme;
}),
map((theme) => theme ?? this.defaultTheme),
);
constructor(
private globalStateProvider: GlobalStateProvider,
private configService: ConfigService,
private defaultTheme: ThemeType = ThemeType.System,
) {}

View File

@@ -1,5 +1,8 @@
import { mock } from "jest-mock-extended";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserKey } from "@bitwarden/common/types/key";
import { makeStaticByteArray, mockEnc } from "../../../../../spec";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
@@ -89,6 +92,7 @@ describe("Send", () => {
it("Decrypt", async () => {
const text = mock<SendText>();
text.decrypt.mockResolvedValue("textView" as any);
const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey;
const send = new Send();
send.id = "id";
@@ -106,13 +110,13 @@ describe("Send", () => {
send.disabled = false;
send.hideEmail = true;
const encryptService = mock<EncryptService>();
const cryptoService = mock<CryptoService>();
cryptoService.decryptToBytes
.calledWith(send.key, null)
encryptService.decryptToBytes
.calledWith(send.key, userKey)
.mockResolvedValue(makeStaticByteArray(32));
cryptoService.makeSendKey.mockResolvedValue("cryptoKey" as any);
const encryptService = mock<EncryptService>();
cryptoService.getUserKey.mockResolvedValue(userKey);
(window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService);

View File

@@ -73,9 +73,11 @@ export class Send extends Domain {
const model = new SendView(this);
const cryptoService = Utils.getContainerService().getCryptoService();
const encryptService = Utils.getContainerService().getEncryptService();
try {
model.key = await cryptoService.decryptToBytes(this.key, null);
const sendKeyEncryptionKey = await cryptoService.getUserKey();
model.key = await encryptService.decryptToBytes(this.key, sendKeyEncryptionKey);
model.cryptoKey = await cryptoService.makeSendKey(model.key);
} catch (e) {
// TODO: error?

View File

@@ -57,6 +57,8 @@ export class SendService implements InternalSendServiceAbstraction {
send.disabled = model.disabled;
send.hideEmail = model.hideEmail;
send.maxAccessCount = model.maxAccessCount;
send.deletionDate = model.deletionDate;
send.expirationDate = model.expirationDate;
if (model.key == null) {
const key = await this.keyGenerationService.createKeyWithPurpose(
128,

View File

@@ -0,0 +1,24 @@
import { Constraints, StateConstraints } from "../types";
// The constraints type shares the properties of the state,
// but never has any members
const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
get() {
return {};
},
});
/** A constraint that does nothing. */
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
/** Instantiate the identity constraint */
constructor() {}
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
adjust(state: State) {
return state;
}
fix(state: State) {
return state;
}
}

View File

@@ -0,0 +1,27 @@
import { StateConstraints } from "../types";
import { isDynamic } from "./state-constraints-dependency";
type TestType = { foo: string };
describe("isDynamic", () => {
it("returns `true` when the constraint fits the `DynamicStateConstraints` type.", () => {
const constraint: any = {
calibrate(state: TestType): StateConstraints<TestType> {
return null;
},
};
const result = isDynamic(constraint);
expect(result).toBeTruthy();
});
it("returns `false` when the constraint fails to fit the `DynamicStateConstraints` type.", () => {
const constraint: any = {};
const result = isDynamic(constraint);
expect(result).toBeFalsy();
});
});

View File

@@ -0,0 +1,29 @@
import { Observable } from "rxjs";
import { DynamicStateConstraints, StateConstraints } from "../types";
/** A pattern for types that depend upon a dynamic set of constraints.
*
* Consumers of this dependency should track the last-received state and
* apply it when application state is received or emitted. If `constraints$`
* emits an unrecoverable error, the consumer should continue using the
* last-emitted constraints. If `constraints$` completes, the consumer should
* continue using the last-emitted constraints.
*/
export type StateConstraintsDependency<State> = {
/** A stream that emits constraints when subscribed and when the
* constraints change. The stream should not emit `null` or
* `undefined`.
*/
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
};
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
* Otherwise, returns false.
* @param constraints the constraint to evaluate.
* */
export function isDynamic<State>(
constraints: StateConstraints<State> | DynamicStateConstraints<State>,
): constraints is DynamicStateConstraints<State> {
return constraints && "calibrate" in constraints;
}

View File

@@ -0,0 +1,31 @@
import { Simplify } from "type-fest";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
import { StateConstraintsDependency } from "./state-constraints-dependency";
/** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> &
Partial<StateConstraintsDependency<State>> & {
/** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns the value to store in state
*/
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
/**
* Compute whether the state should update. If this is not set, values
* provided to `next` always update the state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns `true` if the value should be stored, otherwise `false`.
*/
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
}
>;

View File

@@ -2,13 +2,37 @@ import { BehaviorSubject, of, Subject } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { awaitAsync, FakeSingleUserState } from "../../../spec";
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
import { StateConstraints } from "../types";
import { UserStateSubject } from "./user-state-subject";
const SomeUser = "some user" as UserId;
type TestType = { foo: string };
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
return Object.freeze({
constraints: { foo: { maxLength } },
adjust: function (state: TestType): TestType {
return {
foo: state.foo.slice(0, this.constraints.foo.maxLength),
};
},
fix: function (state: TestType): TestType {
return {
foo: `finalized|${state.foo.slice(0, this.constraints.foo.maxLength)}`,
};
},
});
}
const DynamicFooMaxLength = Object.freeze({
expected: fooMaxLength(0),
calibrate(state: TestType) {
return this.expected;
},
});
describe("UserStateSubject", () => {
describe("dependencies", () => {
it("ignores repeated when$ emissions", async () => {
@@ -54,6 +78,19 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalledTimes(1);
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
const [initResult] = await tracker.pauseUntilReceived(1);
expect(initResult).toEqual({ foo: "ini" });
});
});
describe("next", () => {
@@ -246,6 +283,116 @@ describe("UserStateSubject", () => {
expect(nextValue).toHaveBeenCalled();
});
it("applies constraints$ on init", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const [result] = await tracker.pauseUntilReceived(1);
expect(result).toEqual({ foo: "in" });
});
it("applies dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual).toEqual({ foo: "" });
});
it("applies constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(1));
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "i" });
});
it("applies constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" });
const [, result] = await tracker.pauseUntilReceived(2);
expect(result).toEqual({ foo: "ne" });
});
it("applies latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.next(fooMaxLength(3));
subject.next({ foo: "next" });
const [, , result] = await tracker.pauseUntilReceived(3);
expect(result).toEqual({ foo: "nex" });
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
subject.next({ foo: "next" });
constraints$.next(fooMaxLength(3));
// `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2);
expect(nextResult).toEqual({ foo: "nex" });
});
it("uses the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.error({ some: "error" });
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult).toEqual({ foo: "nex" });
});
it("uses the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(3));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject);
constraints$.complete();
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult).toEqual({ foo: "nex" });
});
});
describe("error", () => {
@@ -474,4 +621,150 @@ describe("UserStateSubject", () => {
expect(subject.userId).toEqual(SomeUser);
});
});
describe("withConstraints$", () => {
it("emits the next value with an empty constraint", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual.state).toEqual(expected);
expect(actual.constraints).toEqual({});
});
it("ceases emissions once the subject completes", async () => {
const initialState = { foo: "init" };
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
const singleUserId$ = new BehaviorSubject(SomeUser);
const subject = new UserStateSubject(state, { singleUserId$ });
const tracker = new ObservableTracker(subject.withConstraints$);
subject.complete();
subject.next({ foo: "ignored" });
const [result] = await tracker.pauseUntilReceived(1);
expect(result.state).toEqual(initialState);
expect(tracker.emissions.length).toEqual(1);
});
it("emits constraints$ on constraints$ emission", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(1);
const emission = tracker.expectEmission();
constraints$.next(expected);
const result = await emission;
expect(result.state).toEqual({ foo: "i" });
expect(result.constraints).toEqual(expected.constraints);
});
it("emits dynamic constraints", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected: TestType = { foo: "next" };
const emission = tracker.expectEmission();
subject.next(expected);
const actual = await emission;
expect(actual.state).toEqual({ foo: "" });
expect(actual.constraints).toEqual(DynamicFooMaxLength.expected.constraints);
});
it("emits constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(2);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const emission = tracker.expectEmission();
subject.next({ foo: "next" });
const result = await emission;
expect(result.state).toEqual({ foo: "ne" });
expect(result.constraints).toEqual(expected.constraints);
});
it("emits the latest constraints$ on next", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new BehaviorSubject(fooMaxLength(2));
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
constraints$.next(expected);
const emission = tracker.expectEmission();
subject.next({ foo: "next" });
const result = await emission;
expect(result.state).toEqual({ foo: "nex" });
expect(result.constraints).toEqual(expected.constraints);
});
it("waits for constraints$", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const constraints$ = new Subject<StateConstraints<TestType>>();
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
const expected = fooMaxLength(3);
subject.next({ foo: "next" });
constraints$.next(expected);
// `init` is also waiting and is processed before `next`
const [, nextResult] = await tracker.pauseUntilReceived(2);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
it("emits the last-emitted value from constraints$ when constraints$ errors", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.error({ some: "error" });
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
it("emits the last-emitted value from constraints$ when constraints$ completes", async () => {
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
const singleUserId$ = new BehaviorSubject(SomeUser);
const expected = fooMaxLength(3);
const constraints$ = new BehaviorSubject(expected);
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
const tracker = new ObservableTracker(subject.withConstraints$);
constraints$.complete();
subject.next({ foo: "next" });
const [, nextResult] = await tracker.pauseUntilReceived(1);
expect(nextResult.state).toEqual({ foo: "nex" });
expect(nextResult.constraints).toEqual(expected.constraints);
});
});
});

View File

@@ -17,37 +17,20 @@ import {
startWith,
Observable,
Subscription,
last,
concat,
combineLatestWith,
catchError,
EMPTY,
} from "rxjs";
import { Simplify } from "type-fest";
import { SingleUserState } from "@bitwarden/common/platform/state";
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
import { WithConstraints } from "../types";
/** dependencies accepted by the user state subject */
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
SingleUserDependency &
Partial<WhenDependency> &
Partial<Dependencies<Dependency>> & {
/** Compute the next stored value. If this is not set, values
* provided to `next` unconditionally override state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns the value to store in state
*/
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
/**
* Compute whether the state should update. If this is not set, values
* provided to `next` always update the state.
* @param current the value stored in state
* @param next the value received by the user state subject's `next` member
* @param dependencies the latest value from `Dependencies<TCombine>`
* @returns `true` if the value should be stored, otherwise `false`.
*/
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
}
>;
import { IdentityConstraint } from "./identity-state-constraint";
import { isDynamic } from "./state-constraints-dependency";
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
/**
* Adapt a state provider to an rxjs subject.
@@ -61,7 +44,7 @@ export type UserStateSubjectDependencies<State, Dependency> = Simplify<
* @template State the state stored by the subject
* @template Dependencies use-specific dependencies provided by the user.
*/
export class UserStateSubject<State, Dependencies = null>
export class UserStateSubject<State extends object, Dependencies = null>
extends Observable<State>
implements SubjectLike<State>
{
@@ -99,6 +82,35 @@ export class UserStateSubject<State, Dependencies = null>
}),
distinctUntilChanged(),
);
const constraints$ = (
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
).pipe(
// FIXME: this should probably log that an error occurred
catchError(() => EMPTY),
);
// normalize input in case this `UserStateSubject` is not the only
// observer of the backing store
const input$ = combineLatest([this.input, constraints$]).pipe(
map(([input, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
const state = calibration.adjust(input);
return state;
}),
);
// when the output subscription completes, its last-emitted value
// loops around to the input for finalization
const finalize$ = this.pipe(
last(),
combineLatestWith(constraints$),
map(([output, constraints]) => {
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
const state = calibration.fix(output);
return state;
}),
);
const updates$ = concat(input$, finalize$);
// observe completion
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
@@ -106,9 +118,24 @@ export class UserStateSubject<State, Dependencies = null>
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
// wire subscriptions
this.outputSubscription = this.state.state$.subscribe(this.output);
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
// wire output before input so that output normalizes the current state
// before any `next` value is processed
this.outputSubscription = this.state.state$
.pipe(
combineLatestWith(constraints$),
map(([rawState, constraints]) => {
const calibration = isDynamic(constraints)
? constraints.calibrate(rawState)
: constraints;
const state = calibration.adjust(rawState);
return {
constraints: calibration.constraints,
state,
};
}),
)
.subscribe(this.output);
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
.pipe(
filter(([_, when]) => when),
map(([state]) => state),
@@ -144,14 +171,19 @@ export class UserStateSubject<State, Dependencies = null>
* @returns the subscription
*/
subscribe(observer?: Partial<Observer<State>> | ((value: State) => void) | null): Subscription {
return this.output.subscribe(observer);
return this.output.pipe(map((wc) => wc.state)).subscribe(observer);
}
// using subjects to ensure the right semantics are followed;
// if greater efficiency becomes desirable, consider implementing
// `SubjectLike` directly
private input = new Subject<State>();
private readonly output = new ReplaySubject<State>(1);
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
/** A stream containing settings and their last-applied constraints. */
get withConstraints$() {
return this.output.asObservable();
}
private inputSubscription: Unsubscribable;
private outputSubscription: Unsubscribable;

View File

@@ -2,8 +2,11 @@ import { Simplify } from "type-fest";
/** Constraints that are shared by all primitive field types */
type PrimitiveConstraint = {
/** presence indicates the field is required */
required?: true;
/** `true` indicates the field is required; otherwise the field is optional */
required?: boolean;
/** `true` indicates the field is immutable; otherwise the field is mutable */
readonly?: boolean;
};
/** Constraints that are shared by string fields */
@@ -23,29 +26,108 @@ type NumberConstraints = {
/** maximum number value. When absent, min value is unbounded. */
max?: number;
/** presence indicates the field only accepts integer values */
integer?: true;
/** requires the number be a multiple of the step value */
/** requires the number be a multiple of the step value;
* this field must be a positive number. +0 and Infinity are
* prohibited. When absent, any number is accepted.
* @remarks set this to `1` to require integer values.
*/
step?: number;
};
/** Constraints that are shared by boolean fields */
type BooleanConstraint = {
/** When present, the boolean field must have the set value.
* When absent or undefined, the boolean field's value is unconstrained.
*/
requiredValue?: boolean;
};
/** Utility type that transforms a type T into its supported validators.
*/
export type Constraint<T> = PrimitiveConstraint &
(T extends string
? StringConstraints
: T extends number
? NumberConstraints
: T extends boolean
? BooleanConstraint
: never);
/** Utility type that transforms keys of T into their supported
* validators.
*/
export type Constraints<T> = {
[Key in keyof T]: Simplify<
PrimitiveConstraint &
(T[Key] extends string
? StringConstraints
: T[Key] extends number
? NumberConstraints
: never)
>;
[Key in keyof T]?: Simplify<Constraint<T[Key]>>;
};
/** Utility type that tracks whether a set of constraints was
* produced by an active policy.
*/
export type PolicyConstraints<T> = {
/** When true, the constraints were derived from an active policy. */
policyInEffect?: boolean;
} & Constraints<T>;
/** utility type for methods that evaluate constraints generically. */
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
export type AnyConstraint = PrimitiveConstraint &
StringConstraints &
NumberConstraints &
BooleanConstraint;
/** Extends state message with constraints that apply to the message. */
export type WithConstraints<State> = {
/** the state */
readonly state: State;
/** the constraints enforced upon the type. */
readonly constraints: Constraints<State>;
};
/** Creates constraints that are applied automatically to application
* state.
* This type is mutually exclusive with `StateConstraints`.
*/
export type DynamicStateConstraints<State> = {
/** Creates constraints with data derived from the input state
* @param state the state from which the constraints are initialized.
* @remarks this is useful for calculating constraints that
* depend upon values from the input state. You should not send these
* constraints to the UI, because that would prevent the UI from
* offering less restrictive constraints.
*/
calibrate: (state: State) => StateConstraints<State>;
};
/** Constraints that are applied automatically to application state.
* This type is mutually exclusive with `DynamicStateConstraints`.
* @remarks this type automatically corrects incoming our outgoing
* data. If you would like to prevent invalid data from being
* applied, use an rxjs filter and evaluate `Constraints<State>`
* instead.
*/
export type StateConstraints<State> = {
/** Well-known constraints of `State` */
readonly constraints: Readonly<Constraints<State>>;
/** Enforces constraints that always hold for the emitted state.
* @remarks This is useful for enforcing "override" constraints,
* such as when a policy requires a value fall within a specific
* range.
* @param state the state pending emission from the subject.
* @return the value emitted by the subject
*/
adjust: (state: State) => State;
/** Enforces constraints that holds when the subject completes.
* @remarks This is useful for enforcing "default" constraints,
* such as when a policy requires some state is true when data is
* first subscribed, but the state may vary thereafter.
* @param state the state of the subject immediately before
* completion.
* @return the value stored to state upon completion.
*/
fix: (state: State) => State;
};
/** Options that provide contextual information about the application state
* when a generator is invoked.

View File

@@ -15,7 +15,7 @@ export abstract class FolderService implements UserKeyRotationDataProvider<Folde
folderViews$: Observable<FolderView[]>;
clearCache: () => Promise<void>;
encrypt: (model: FolderView, key?: SymmetricCryptoKey) => Promise<Folder>;
encrypt: (model: FolderView, key: SymmetricCryptoKey) => Promise<Folder>;
get: (id: string) => Promise<Folder>;
getDecrypted$: (id: string) => Observable<FolderView | undefined>;
getAllFromState: () => Promise<Folder[]>;

View File

@@ -0,0 +1,7 @@
/**
* This interface defines the a contract for a service that prompts the user to upgrade to premium.
* It ensures that PremiumUpgradePromptService contains a promptForPremium method.
*/
export abstract class PremiumUpgradePromptService {
abstract promptForPremium(organizationId?: string): Promise<void>;
}

View File

@@ -1,8 +1,8 @@
import { Jsonify } from "type-fest";
import { normalizeExpiryYearFormat } from "../../../autofill/utils";
import { CardLinkedId as LinkedId } from "../../enums";
import { linkedFieldOption } from "../../linked-field-option.decorator";
import { normalizeExpiryYearFormat } from "../../utils";
import { ItemView } from "./item.view";

View File

@@ -145,6 +145,7 @@ describe("Cipher Service", () => {
cipherFileUploadService,
configService,
stateProvider,
accountService,
);
cipherObj = new Cipher(cipherData);
@@ -273,7 +274,7 @@ describe("Cipher Service", () => {
cryptoService.makeCipherKey.mockReturnValue(
Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey),
);
cryptoService.encrypt.mockImplementation(encryptText);
encryptService.encrypt.mockImplementation(encryptText);
jest.spyOn(cipherService as any, "getAutofillOnPageLoadDefault").mockResolvedValue(true);
});
@@ -285,6 +286,10 @@ describe("Cipher Service", () => {
{ uri: "uri", match: UriMatchStrategy.RegularExpression } as LoginUriView,
];
cryptoService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const domain = await cipherService.encrypt(cipherView, userId);
expect(domain.login.uris).toEqual([
@@ -301,6 +306,9 @@ describe("Cipher Service", () => {
it("is null when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
cryptoService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
const cipher = await cipherService.encrypt(cipherView, userId);
expect(cipher.key).toBeNull();
@@ -322,6 +330,9 @@ describe("Cipher Service", () => {
it("is not called when feature flag is false", async () => {
configService.getFeatureFlag.mockResolvedValue(false);
cryptoService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
await cipherService.encrypt(cipherView, userId);
@@ -330,6 +341,9 @@ describe("Cipher Service", () => {
it("is called when feature flag is true", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
cryptoService.getOrgKey.mockReturnValue(
Promise.resolve<any>(new SymmetricCryptoKey(new Uint8Array(32)) as OrgKey),
);
await cipherService.encrypt(cipherView, userId);

View File

@@ -1,6 +1,7 @@
import { firstValueFrom, map, Observable, skipWhile, switchMap } from "rxjs";
import { SemVer } from "semver";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { BulkEncryptService } from "@bitwarden/common/platform/abstractions/bulk-encrypt.service";
@@ -108,6 +109,7 @@ export class CipherService implements CipherServiceAbstraction {
private cipherFileUploadService: CipherFileUploadService,
private configService: ConfigService,
private stateProvider: StateProvider,
private accountService: AccountService,
) {
this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY);
this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS);
@@ -165,7 +167,7 @@ export class CipherService implements CipherServiceAbstraction {
async encrypt(
model: CipherView,
userId: UserId,
keyForEncryption?: SymmetricCryptoKey,
keyForCipherEncryption?: SymmetricCryptoKey,
keyForCipherKeyDecryption?: SymmetricCryptoKey,
originalCipher: Cipher = null,
): Promise<Cipher> {
@@ -195,26 +197,21 @@ export class CipherService implements CipherServiceAbstraction {
const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher, userId);
// The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled.
// If the caller has provided a key for cipher key encryption, use it. Otherwise, use the user or org key.
keyForEncryption ||= userOrOrgKey;
keyForCipherEncryption ||= userOrOrgKey;
// If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key.
keyForCipherKeyDecryption ||= userOrOrgKey;
return this.encryptCipherWithCipherKey(
model,
cipher,
keyForEncryption,
keyForCipherEncryption,
keyForCipherKeyDecryption,
);
} else {
if (keyForEncryption == null && cipher.organizationId != null) {
keyForEncryption = await this.cryptoService.getOrgKey(cipher.organizationId);
if (keyForEncryption == null) {
throw new Error("Cannot encrypt cipher for organization. No key.");
}
}
keyForCipherEncryption ||= await this.getKeyForCipherKeyDecryption(cipher, userId);
// We want to ensure that the cipher key is null if cipher key encryption is disabled
// so that decryption uses the proper key.
cipher.key = null;
return this.encryptCipher(model, cipher, keyForEncryption);
return this.encryptCipher(model, cipher, keyForCipherEncryption);
}
}
@@ -243,7 +240,7 @@ export class CipherService implements CipherServiceAbstraction {
key,
).then(async () => {
if (model.key != null) {
attachment.key = await this.cryptoService.encrypt(model.key.key, key);
attachment.key = await this.encryptService.encrypt(model.key.key, key);
}
encAttachments.push(attachment);
});
@@ -1348,7 +1345,9 @@ export class CipherService implements CipherServiceAbstraction {
}
const encBuf = await EncArrayBuffer.fromResponse(attachmentResponse);
const decBuf = await this.cryptoService.decryptFromBytes(encBuf, null);
const activeUserId = await firstValueFrom(this.accountService.activeAccount$);
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeUserId.id);
const decBuf = await this.encryptService.decryptToBytes(encBuf, userKey);
let encKey: UserKey | OrgKey;
encKey = await this.cryptoService.getOrgKey(organizationId);
@@ -1412,7 +1411,7 @@ export class CipherService implements CipherServiceAbstraction {
.then(() => {
const modelProp = (model as any)[map[theProp] || theProp];
if (modelProp && modelProp !== "") {
return self.cryptoService.encrypt(modelProp, key);
return self.encryptService.encrypt(modelProp, key);
}
return null;
})
@@ -1458,7 +1457,7 @@ export class CipherService implements CipherServiceAbstraction {
key,
);
const uriHash = await this.encryptService.hash(model.login.uris[i].uri, "sha256");
loginUri.uriChecksum = await this.cryptoService.encrypt(uriHash, key);
loginUri.uriChecksum = await this.encryptService.encrypt(uriHash, key);
cipher.login.uris.push(loginUri);
}
}
@@ -1485,8 +1484,8 @@ export class CipherService implements CipherServiceAbstraction {
},
key,
);
domainKey.counter = await this.cryptoService.encrypt(String(viewKey.counter), key);
domainKey.discoverable = await this.cryptoService.encrypt(
domainKey.counter = await this.encryptService.encrypt(String(viewKey.counter), key);
domainKey.discoverable = await this.encryptService.encrypt(
String(viewKey.discoverable),
key,
);
@@ -1605,11 +1604,23 @@ export class CipherService implements CipherServiceAbstraction {
this.sortedCiphersCache.clear();
}
/**
* Encrypts a cipher object.
* @param model The cipher view model.
* @param cipher The cipher object.
* @param key The encryption key to encrypt with. This can be the org key, user key or cipher key, but must never be null
*/
private async encryptCipher(
model: CipherView,
cipher: Cipher,
key: SymmetricCryptoKey,
): Promise<Cipher> {
if (key == null) {
throw new Error(
"Key to encrypt cipher must not be null. Use the org key, user key or cipher key.",
);
}
await Promise.all([
this.encryptObjProperty(
model,

View File

@@ -1,6 +1,8 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { Jsonify } from "type-fest";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { Utils } from "../../platform/misc/utils";
@@ -61,6 +63,7 @@ export class CollectionService implements CollectionServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private encryptService: EncryptService,
private i18nService: I18nService,
protected stateProvider: StateProvider,
) {
@@ -101,7 +104,7 @@ export class CollectionService implements CollectionServiceAbstraction {
collection.organizationId = model.organizationId;
collection.readOnly = model.readOnly;
collection.externalId = model.externalId;
collection.name = await this.cryptoService.encrypt(model.name, key);
collection.name = await this.encryptService.encrypt(model.name, key);
return collection;
}

View File

@@ -49,7 +49,13 @@ describe("Folder Service", () => {
);
encryptService.decryptToUtf8.mockResolvedValue("DEC");
folderService = new FolderService(cryptoService, i18nService, cipherService, stateProvider);
folderService = new FolderService(
cryptoService,
encryptService,
i18nService,
cipherService,
stateProvider,
);
folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS);
@@ -62,9 +68,9 @@ describe("Folder Service", () => {
model.id = "2";
model.name = "Test Folder";
cryptoService.encrypt.mockResolvedValue(new EncString("ENC"));
encryptService.encrypt.mockResolvedValue(new EncString("ENC"));
const result = await folderService.encrypt(model);
const result = await folderService.encrypt(model, null);
expect(result).toEqual({
id: "2",
@@ -185,7 +191,7 @@ describe("Folder Service", () => {
beforeEach(() => {
encryptedKey = new EncString("Re-encrypted Folder");
cryptoService.encrypt.mockResolvedValue(encryptedKey);
encryptService.encrypt.mockResolvedValue(encryptedKey);
});
it("returns re-encrypted user folders", async () => {

View File

@@ -1,5 +1,7 @@
import { Observable, firstValueFrom, map, shareReplay } from "rxjs";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { Utils } from "../../../platform/misc/utils";
@@ -25,6 +27,7 @@ export class FolderService implements InternalFolderServiceAbstraction {
constructor(
private cryptoService: CryptoService,
private encryptService: EncryptService,
private i18nService: I18nService,
private cipherService: CipherService,
private stateProvider: StateProvider,
@@ -48,10 +51,10 @@ export class FolderService implements InternalFolderServiceAbstraction {
}
// TODO: This should be moved to EncryptService or something
async encrypt(model: FolderView, key?: SymmetricCryptoKey): Promise<Folder> {
async encrypt(model: FolderView, key: SymmetricCryptoKey): Promise<Folder> {
const folder = new Folder();
folder.id = model.id;
folder.name = await this.cryptoService.encrypt(model.name, key);
folder.name = await this.encryptService.encrypt(model.name, key);
return folder;
}

View File

@@ -1,122 +0,0 @@
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { normalizeExpiryYearFormat, isCardExpired } from "@bitwarden/common/vault/utils";
function getExpiryYearValueFormats(currentCentury: string) {
return [
[-12, `${currentCentury}12`],
[0, `${currentCentury}00`],
[2043, "2043"], // valid year with a length of four should be taken directly
[24, `${currentCentury}24`],
[3054, "3054"], // valid year with a length of four should be taken directly
[31423524543, `${currentCentury}43`],
[4, `${currentCentury}04`],
[null, null],
[undefined, null],
["-12", `${currentCentury}12`],
["", null],
["0", `${currentCentury}00`],
["00", `${currentCentury}00`],
["000", `${currentCentury}00`],
["0000", `${currentCentury}00`],
["00000", `${currentCentury}00`],
["0234234", `${currentCentury}34`],
["04", `${currentCentury}04`],
["2043", "2043"], // valid year with a length of four should be taken directly
["24", `${currentCentury}24`],
["3054", "3054"], // valid year with a length of four should be taken directly
["31423524543", `${currentCentury}43`],
["4", `${currentCentury}04`],
["aaaa", null],
["adgshsfhjsdrtyhsrth", null],
["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`],
];
}
describe("normalizeExpiryYearFormat", () => {
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
const formattedValue = normalizeExpiryYearFormat(inputValue);
expect(formattedValue).toEqual(expectedValue);
});
});
describe("in the year 3107", () => {
const theDistantFuture = new Date(Date.UTC(3107, 1, 1));
jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf());
beforeAll(() => {
jest.useFakeTimers({ advanceTimers: true });
jest.setSystemTime(theDistantFuture);
});
afterAll(() => {
jest.useRealTimers();
});
const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2);
expect(currentCentury).toBe("31");
const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury);
expiryYearValueFormats.forEach(([inputValue, expectedValue]) => {
it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => {
const formattedValue = normalizeExpiryYearFormat(inputValue);
expect(formattedValue).toEqual(expectedValue);
});
});
jest.clearAllTimers();
});
});
function getCardExpiryDateValues() {
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
// `Date` months are zero-indexed, our expiry date month inputs are one-indexed
const currentMonth = currentDate.getMonth() + 1;
return [
[null, null, false], // no month, no year
[undefined, undefined, false], // no month, no year, invalid values
["", "", false], // no month, no year, invalid values
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
["0", `${currentYear - 1}`, true], // invalid 0 month
["00", `${currentYear + 1}`, false], // invalid 0 month
[`${currentMonth}`, "0000", true], // current month, in the year 2000
[null, `${currentYear}`.slice(-2), false], // no month, this year
[null, `${currentYear - 1}`.slice(-2), true], // no month, last year
["1", null, false], // no year, January
["1", `${currentYear - 1}`, true], // January last year
["13", `${currentYear}`, false], // 12 + 1 is Feb. in the next year (Date is zero-indexed)
[`${currentMonth + 36}`, `${currentYear - 1}`, true], // even though the month value would put the date 3 years into the future when calculated with `Date`, an explicit year in the past indicates the card is expired
[`${currentMonth}`, `${currentYear}`, false], // this year, this month (not expired until the month is over)
[`${currentMonth}`, `${currentYear}`.slice(-2), false], // This month, this year (not expired until the month is over)
[`${currentMonth - 1}`, `${currentYear}`, true], // last month
[`${currentMonth - 1}`, `${currentYear + 1}`, false], // 11 months from now
];
}
describe("isCardExpired", () => {
const expiryYearValueFormats = getCardExpiryDateValues();
expiryYearValueFormats.forEach(
([inputMonth, inputYear, expectedValue]: [string | null, string | null, boolean]) => {
it(`should return ${expectedValue} when the card expiry month is ${inputMonth} and the card expiry year is ${inputYear}`, () => {
const testCardView = new CardView();
testCardView.expMonth = inputMonth;
testCardView.expYear = inputYear;
const cardIsExpired = isCardExpired(testCardView);
expect(cardIsExpired).toBe(expectedValue);
});
},
);
});

View File

@@ -1,83 +0,0 @@
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`;
/**
* Takes a string or number value and returns a string value formatted as a valid 4-digit year
*
* @export
* @param {(string | number)} yearInput
* @return {*} {(Year | null)}
*/
export function normalizeExpiryYearFormat(yearInput: string | number): Year | null {
// The input[type="number"] is returning a number, convert it to a string
// An empty field returns null, avoid casting `"null"` to a string
const yearInputIsEmpty = yearInput == null || yearInput === "";
let expirationYear = yearInputIsEmpty ? null : `${yearInput}`;
// Exit early if year is already formatted correctly or empty
if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) {
return expirationYear as Year;
}
expirationYear = expirationYear
// For safety, because even input[type="number"] will allow decimals
.replace(/[^\d]/g, "")
// remove any leading zero padding (leave the last leading zero if it ends the string)
.replace(/^[0]+(?=.)/, "");
if (expirationYear === "") {
expirationYear = null;
}
// given the context of payment card expiry, a year character length of 3, or over 4
// is more likely to be a mistake than an intentional value for the far past or far future.
if (expirationYear && expirationYear.length !== 4) {
const paddedYear = ("00" + expirationYear).slice(-2);
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
expirationYear = currentCentury + paddedYear;
}
return expirationYear as Year | null;
}
/**
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
* the card is expired.
*
* @export
* @param {CardView} cipherCard
* @return {*} {boolean}
*/
export function isCardExpired(cipherCard: CardView): boolean {
if (cipherCard) {
const { expMonth = null, expYear = null } = cipherCard;
const now = new Date();
const normalizedYear = normalizeExpiryYearFormat(expYear);
// If the card year is before the current year, don't bother checking the month
if (normalizedYear && parseInt(normalizedYear) < now.getFullYear()) {
return true;
}
if (normalizedYear && expMonth) {
// `Date` months are zero-indexed
const parsedMonth =
parseInt(expMonth) - 1 ||
// Add a month floor of 0 to protect against an invalid low month value of "0"
0;
const parsedYear = parseInt(normalizedYear);
// First day of the next month minus one, to get last day of the card month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 0);
return cardExpiry < now;
}
}
return false;
}

View File

@@ -1,17 +1,18 @@
<div
bitTypography="body2"
class="tw-inline-flex tw-items-center tw-rounded-full tw-max-w-52 tw-border-solid tw-border tw-border-text-muted"
class="tw-inline-flex tw-items-center tw-rounded-full tw-border-solid tw-border tw-border-text-muted"
[ngClass]="[
selectedOption
? 'tw-bg-text-muted tw-text-contrast tw-gap-1'
: 'tw-bg-transparent tw-text-muted tw-gap-1.5',
focusVisibleWithin() ? 'tw-ring-2 tw-ring-primary-500 tw-ring-offset-1' : '',
fullWidth ? 'tw-w-full' : 'tw-max-w-52',
]"
>
<!-- Primary button -->
<button
type="button"
class="fvw-target tw-inline-flex tw-gap-1.5 tw-items-center tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-max-w-full tw-py-1 tw-pl-3 last:tw-pr-3 tw-truncate tw-text-[inherit]"
class="fvw-target tw-inline-flex tw-gap-1.5 tw-items-center tw-justify-between tw-bg-transparent hover:tw-bg-transparent tw-border-none tw-outline-none tw-w-full tw-py-1 tw-pl-3 last:tw-pr-3 tw-truncate tw-text-[inherit]"
[ngClass]="{
'tw-cursor-not-allowed': disabled,
}"
@@ -20,8 +21,10 @@
[title]="label"
#menuTrigger="menuTrigger"
>
<i class="bwi !tw-text-[inherit]" [ngClass]="icon"></i>
<span class="tw-truncate">{{ label }}</span>
<span class="tw-inline-flex tw-items-center tw-gap-1.5 tw-truncate">
<i class="bwi !tw-text-[inherit]" [ngClass]="icon"></i>
<span class="tw-truncate">{{ label }}</span>
</span>
<i
*ngIf="!selectedOption"
class="bwi"
@@ -52,10 +55,10 @@
type="button"
bitMenuItem
(click)="viewOption(parent, $event)"
[title]="parent.label ? ('backTo' | i18n: parent.label) : ('back' | i18n)"
[title]="'backTo' | i18n: parent.label ?? placeholderText"
>
<i slot="start" class="bwi bwi-angle-left" aria-hidden="true"></i>
{{ parent.label ? ("backTo" | i18n: parent.label) : ("back" | i18n) }}
{{ "backTo" | i18n: parent.label ?? placeholderText }}
</button>
<button
@@ -76,6 +79,7 @@
(click)="option.children?.length ? viewOption(option, $event) : selectOption(option, $event)"
[disabled]="option.disabled"
[title]="option.label"
[attr.aria-haspopup]="option.children?.length ? 'menu' : null"
>
<i
*ngIf="option.icon"

View File

@@ -1,10 +1,23 @@
import { Component, HostListener, Input, booleanAttribute, signal } from "@angular/core";
import {
AfterViewInit,
Component,
DestroyRef,
HostListener,
Input,
QueryList,
ViewChild,
ViewChildren,
booleanAttribute,
inject,
signal,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { compareValues } from "../../../common/src/platform/misc/compare-values";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { MenuModule } from "../menu";
import { MenuComponent, MenuItemDirective, MenuModule } from "../menu";
import { Option } from "../select/option";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
@@ -28,7 +41,10 @@ export type ChipSelectOption<T> = Option<T> & {
},
],
})
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, AfterViewInit {
@ViewChild(MenuComponent) menu: MenuComponent;
@ViewChildren(MenuItemDirective) menuItems: QueryList<MenuItemDirective>;
/** Text to show when there is no selected option */
@Input({ required: true }) placeholderText: string;
@@ -49,6 +65,9 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Disables the entire chip */
@Input({ transform: booleanAttribute }) disabled = false;
/** Chip will stretch to full width of its container */
@Input({ transform: booleanAttribute }) fullWidth?: boolean;
/**
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
*/
@@ -62,6 +81,8 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
this.focusVisibleWithin.set(false);
}
private destroyRef = inject(DestroyRef);
/** Tree constructed from `this.options` */
private rootTree: ChipSelectOption<T>;
@@ -148,6 +169,16 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
this.renderedOptions = this.rootTree;
}
ngAfterViewInit() {
/**
* menuItems will change when the user navigates into or out of a submenu. when that happens, we want to
* direct their focus to the first item in the new menu
*/
this.menuItems.changes.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.menu.keyManager.setFirstItemActive();
});
}
/** Control Value Accessor */
private notifyOnChange?: (value: T) => void;

View File

@@ -74,6 +74,44 @@ export const Default: Story = {
},
};
export const FullWidth: Story = {
render: (args) => ({
props: {
...args,
},
template: /* html */ `
<div class="tw-w-40">
<bit-chip-select
placeholderText="Folder"
placeholderIcon="bwi-folder"
[options]="options"
[ngModel]="value"
fullWidth
></bit-chip-select>
</div>
`,
}),
args: {
options: [
{
label: "Foo",
value: "foo",
icon: "bwi-folder",
},
{
label: "Bar",
value: "bar",
icon: "bwi-exclamation-triangle tw-text-danger",
},
{
label: "Baz",
value: "baz",
disabled: true,
},
],
},
};
export const NestedOptions: Story = {
...Default,
args: {

View File

@@ -10,7 +10,7 @@
<input
#input
bitInput
type="search"
[type]="inputType"
[id]="id"
[placeholder]="placeholder ?? ('search' | i18n)"
class="tw-rounded-l tw-pl-9"

View File

@@ -1,6 +1,8 @@
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { isBrowserSafariApi } from "@bitwarden/platform";
import { FocusableElement } from "../shared/focusable-element";
let nextId = 0;
@@ -28,6 +30,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
protected id = `search-id-${nextId++}`;
protected searchText: string;
// Use `type="text"` for Safari to improve rendering performance
protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const);
@Input() disabled: boolean;
@Input() placeholder: string;

View File

@@ -20,7 +20,8 @@
"lib": ["es2020", "dom"],
"paths": {
"@bitwarden/common/*": ["../common/src/*"],
"@bitwarden/angular/*": ["../angular/src/*"]
"@bitwarden/angular/*": ["../angular/src/*"],
"@bitwarden/platform": ["../platform/src"]
}
},
"angularCompilerOptions": {

View File

@@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -19,6 +20,7 @@ import { emptyUnencryptedExport } from "./test-data/bitwarden-json/unencrypted.j
describe("BitwardenPasswordProtectedImporter", () => {
let importer: BitwardenPasswordProtectedImporter;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let i18nService: MockProxy<I18nService>;
let cipherService: MockProxy<CipherService>;
let pinService: MockProxy<PinServiceAbstraction>;
@@ -30,6 +32,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
beforeEach(() => {
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
i18nService = mock<I18nService>();
cipherService = mock<CipherService>();
pinService = mock<PinServiceAbstraction>();
@@ -37,6 +40,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
importer = new BitwardenPasswordProtectedImporter(
cryptoService,
encryptService,
i18nService,
cipherService,
pinService,
@@ -91,7 +95,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
});
it("succeeds with default jdoc", async () => {
cryptoService.decryptToUtf8.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
encryptService.decryptToUtf8.mockReturnValue(Promise.resolve(emptyUnencryptedExport));
expect((await importer.parse(JSON.stringify(jDoc))).success).toEqual(true);
});

View File

@@ -85,7 +85,7 @@ describe("Protonpass Json Importer", () => {
// "My Secure Note" is assigned to folder "Personal"
expect(result.folderRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.folderRelationships[3]).toEqual([3, 1]);
expect(result.folderRelationships[4]).toEqual([4, 1]);
});
it("should create collections if part of an organization", async () => {
@@ -102,7 +102,7 @@ describe("Protonpass Json Importer", () => {
// "My Secure Note" is assigned to folder "Personal"
expect(result.collectionRelationships[1]).toEqual([1, 0]);
// "Other vault login" is assigned to folder "Test"
expect(result.collectionRelationships[3]).toEqual([3, 1]);
expect(result.collectionRelationships[4]).toEqual([4, 1]);
});
it("should not add deleted items", async () => {
@@ -114,7 +114,7 @@ describe("Protonpass Json Importer", () => {
expect(cipher.name).not.toBe("My Deleted Note");
}
expect(ciphers.length).toBe(4);
expect(ciphers.length).toBe(5);
});
it("should set favorites", async () => {
@@ -126,4 +126,97 @@ describe("Protonpass Json Importer", () => {
expect(ciphers[1].favorite).toBe(false);
expect(ciphers[2].favorite).toBe(true);
});
it("should skip unsupported items", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
const ciphers = result.ciphers;
expect(ciphers.length).toBe(5);
expect(ciphers[4].type).toEqual(CipherType.Login);
});
it("should parse identity data", async () => {
const testDataJson = JSON.stringify(testData);
const result = await importer.parse(testDataJson);
expect(result != null).toBe(true);
result.ciphers.shift();
result.ciphers.shift();
result.ciphers.shift();
const cipher = result.ciphers.shift();
expect(cipher.type).toEqual(CipherType.Identity);
expect(cipher.identity.firstName).toBe("Test");
expect(cipher.identity.middleName).toBe("1");
expect(cipher.identity.lastName).toBe("1");
expect(cipher.identity.email).toBe("test@gmail.com");
expect(cipher.identity.phone).toBe("7507951789");
expect(cipher.identity.company).toBe("Bitwarden");
expect(cipher.identity.ssn).toBe("98378264782");
expect(cipher.identity.passportNumber).toBe("7173716378612");
expect(cipher.identity.licenseNumber).toBe("21234");
expect(cipher.identity.address1).toBe("Bitwarden");
expect(cipher.identity.address2).toBe("23 Street");
expect(cipher.identity.address3).toBe("12th Foor Test County");
expect(cipher.identity.city).toBe("New York");
expect(cipher.identity.state).toBe("Test");
expect(cipher.identity.postalCode).toBe("4038456");
expect(cipher.identity.country).toBe("US");
expect(cipher.fields.length).toEqual(13);
expect(cipher.fields.at(0).name).toEqual("gender");
expect(cipher.fields.at(0).value).toEqual("Male");
expect(cipher.fields.at(0).type).toEqual(FieldType.Text);
expect(cipher.fields.at(1).name).toEqual("TestPersonal");
expect(cipher.fields.at(1).value).toEqual("Personal");
expect(cipher.fields.at(1).type).toEqual(FieldType.Text);
expect(cipher.fields.at(2).name).toEqual("TestAddress");
expect(cipher.fields.at(2).value).toEqual("Address");
expect(cipher.fields.at(2).type).toEqual(FieldType.Text);
expect(cipher.fields.at(3).name).toEqual("xHandle");
expect(cipher.fields.at(3).value).toEqual("@twiter");
expect(cipher.fields.at(3).type).toEqual(FieldType.Text);
expect(cipher.fields.at(4).name).toEqual("secondPhoneNumber");
expect(cipher.fields.at(4).value).toEqual("243538978");
expect(cipher.fields.at(4).type).toEqual(FieldType.Text);
expect(cipher.fields.at(5).name).toEqual("instagram");
expect(cipher.fields.at(5).value).toEqual("@insta");
expect(cipher.fields.at(5).type).toEqual(FieldType.Text);
expect(cipher.fields.at(6).name).toEqual("TestContact");
expect(cipher.fields.at(6).value).toEqual("Contact");
expect(cipher.fields.at(6).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(7).name).toEqual("jobTitle");
expect(cipher.fields.at(7).value).toEqual("Engineer");
expect(cipher.fields.at(7).type).toEqual(FieldType.Text);
expect(cipher.fields.at(8).name).toEqual("workPhoneNumber");
expect(cipher.fields.at(8).value).toEqual("78236476238746");
expect(cipher.fields.at(8).type).toEqual(FieldType.Text);
expect(cipher.fields.at(9).name).toEqual("TestWork");
expect(cipher.fields.at(9).value).toEqual("Work");
expect(cipher.fields.at(9).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(10).name).toEqual("TestSection");
expect(cipher.fields.at(10).value).toEqual("Section");
expect(cipher.fields.at(10).type).toEqual(FieldType.Text);
expect(cipher.fields.at(11).name).toEqual("TestSectionHidden");
expect(cipher.fields.at(11).value).toEqual("SectionHidden");
expect(cipher.fields.at(11).type).toEqual(FieldType.Hidden);
expect(cipher.fields.at(12).name).toEqual("TestExtra");
expect(cipher.fields.at(12).value).toEqual("Extra");
expect(cipher.fields.at(12).type).toEqual(FieldType.Text);
});
});

View File

@@ -138,6 +138,144 @@ export const testData: ProtonPassJsonFile = {
modifyTime: 1689182908,
pinned: false,
},
{
itemId:
"gliCOyyJOsoBf5QIijvCF4QsPij3q_MR4nCXZ2sXm7YCJCfHjrRD_p2XG9vLsaytErsQvMhcLISVS7q8-7SCkg==",
shareId:
"TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==",
data: {
metadata: {
name: "Identity",
note: "",
itemUuid: "c2e52768",
},
extraFields: [
{
fieldName: "TestExtra",
type: "text",
data: {
content: "Extra",
},
},
],
type: "identity",
content: {
fullName: "Test 1",
email: "test@gmail.com",
phoneNumber: "7507951789",
firstName: "Test",
middleName: "1",
lastName: "Test",
birthdate: "",
gender: "Male",
extraPersonalDetails: [
{
fieldName: "TestPersonal",
type: "text",
data: {
content: "Personal",
},
},
],
organization: "Bitwarden",
streetAddress: "23 Street",
zipOrPostalCode: "4038456",
city: "New York",
stateOrProvince: "Test",
countryOrRegion: "US",
floor: "12th Foor",
county: "Test County",
extraAddressDetails: [
{
fieldName: "TestAddress",
type: "text",
data: {
content: "Address",
},
},
],
socialSecurityNumber: "98378264782",
passportNumber: "7173716378612",
licenseNumber: "21234",
website: "",
xHandle: "@twiter",
secondPhoneNumber: "243538978",
linkedin: "",
reddit: "",
facebook: "",
yahoo: "",
instagram: "@insta",
extraContactDetails: [
{
fieldName: "TestContact",
type: "hidden",
data: {
content: "Contact",
},
},
],
company: "Bitwarden",
jobTitle: "Engineer",
personalWebsite: "",
workPhoneNumber: "78236476238746",
workEmail: "",
extraWorkDetails: [
{
fieldName: "TestWork",
type: "hidden",
data: {
content: "Work",
},
},
],
extraSections: [
{
sectionName: "TestSection",
sectionFields: [
{
fieldName: "TestSection",
type: "text",
data: {
content: "Section",
},
},
{
fieldName: "TestSectionHidden",
type: "hidden",
data: {
content: "SectionHidden",
},
},
],
},
],
},
},
state: 1,
aliasEmail: null,
contentFormatVersion: 6,
createTime: 1725707298,
modifyTime: 1725707298,
pinned: false,
},
{
itemId:
"WTKLZtKfHIC3Gv7gRXUANifNjj0gN3P_52I4MznAzig9GSb_OgJ0qcZ8taOZyfsFTLOWBslXwI-HSMWXVmnKzQ==",
shareId:
"TpawpLbs1nuUlQUCtgKZgb3zgAvbrGrOaqOylKqVe_RLROEyUvMq8_ZEuGw73PGRUSr89iNtQ2NosuggP54nwA==",
data: {
metadata: { name: "Alias", note: "", itemUuid: "576f14fa" },
extraFields: [],
type: "alias",
content: {},
},
state: 1,
aliasEmail: "alias.removing005@passinbox.com",
contentFormatVersion: 6,
createTime: 1725708208,
modifyTime: 1725708208,
pinned: false,
},
],
},
REDACTED_VAULT_ID_B: {

View File

@@ -30,6 +30,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { ClientType } from "@bitwarden/common/enums";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -89,6 +90,7 @@ const safeProviders: SafeProvider[] = [
I18nService,
CollectionService,
CryptoService,
EncryptService,
PinServiceAbstraction,
AccountService,
],

View File

@@ -1,5 +1,6 @@
import * as papa from "papaparse";
import { normalizeExpiryYearFormat } from "@bitwarden/common/autofill/utils";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
@@ -11,7 +12,6 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.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 { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
import { ImportResult } from "../models/import-result";

View File

@@ -8,8 +8,10 @@ import {
FolderWithIdExport,
} from "@bitwarden/common/models/export";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view";
@@ -31,6 +33,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
protected constructor(
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
protected i18nService: I18nService,
protected cipherService: CipherService,
protected pinService: PinServiceAbstraction,
@@ -60,11 +63,16 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer {
results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport,
) {
if (results.encKeyValidation_DO_NOT_EDIT != null) {
const orgKey = await this.cryptoService.getOrgKey(this.organizationId);
let keyForDecryption: SymmetricCryptoKey = await this.cryptoService.getOrgKey(
this.organizationId,
);
if (keyForDecryption == null) {
keyForDecryption = await this.cryptoService.getUserKeyWithLegacySupport();
}
const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.cryptoService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
encKeyValidation,
orgKey,
keyForDecryption,
);
if (encKeyValidationDecrypt === null) {
this.result.success = false;

View File

@@ -6,6 +6,7 @@ import {
PBKDF2KdfConfig,
} from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
@@ -23,13 +24,14 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
constructor(
cryptoService: CryptoService,
encryptService: EncryptService,
i18nService: I18nService,
cipherService: CipherService,
pinService: PinServiceAbstraction,
accountService: AccountService,
private promptForPassword_callback: () => Promise<string>,
) {
super(cryptoService, i18nService, cipherService, pinService, accountService);
super(cryptoService, encryptService, i18nService, cipherService, pinService, accountService);
}
async parse(data: string): Promise<ImportResult> {
@@ -65,7 +67,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
}
const encData = new EncString(parsedData.data);
const clearTextData = await this.cryptoService.decryptToUtf8(encData, this.key);
const clearTextData = await this.encryptService.decryptToUtf8(encData, this.key);
return await super.parse(clearTextData);
}
@@ -86,7 +88,7 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im
const encKeyValidation = new EncString(jdoc.encKeyValidation_DO_NOT_EDIT);
const encKeyValidationDecrypt = await this.cryptoService.decryptToUtf8(
const encKeyValidationDecrypt = await this.encryptService.decryptToUtf8(
encKeyValidation,
this.key,
);

View File

@@ -0,0 +1,66 @@
import { processNames } from "./protonpass-import-utils";
describe("processNames", () => {
it("should use only fullName to map names if it contains at least three words, ignoring individual name fields", () => {
const result = processNames("Alice Beth Carter", "Kevin", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should map extra words to the middle name if fullName contains more than three words", () => {
const result = processNames("Alice Beth Middle Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth Middle",
mappedLastName: "Carter",
});
});
it("should map names correctly even if fullName has words separated by more than one space", () => {
const result = processNames("Alice Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "",
mappedLastName: "Carter",
});
});
it("should handle a single name in fullName and use middleName and lastName to populate rest of names", () => {
const result = processNames("Alice", "", "Beth", "Carter");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should correctly map fullName when it only contains two words", () => {
const result = processNames("Alice Carter", "", "", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "",
mappedLastName: "Carter",
});
});
it("should map middle name from middleName if fullName only contains two words", () => {
const result = processNames("Alice Carter", "", "Beth", "");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
it("should fall back to firstName, middleName, and lastName if fullName is empty", () => {
const result = processNames("", "Alice", "Beth", "Carter");
expect(result).toEqual({
mappedFirstName: "Alice",
mappedMiddleName: "Beth",
mappedLastName: "Carter",
});
});
});

View File

@@ -0,0 +1,21 @@
export function processNames(
fullname: string | null,
firstname: string | null,
middlename: string | null,
lastname: string | null,
) {
let mappedFirstName = firstname;
let mappedMiddleName = middlename;
let mappedLastName = lastname;
if (fullname) {
const parts = fullname.trim().split(/\s+/);
// Assign parts to first, middle, and last name based on the number of parts
mappedFirstName = parts[0] || firstname;
mappedLastName = parts.length > 1 ? parts[parts.length - 1] : lastname;
mappedMiddleName = parts.length > 2 ? parts.slice(1, -1).join(" ") : middlename;
}
return { mappedFirstName, mappedMiddleName, mappedLastName };
}

View File

@@ -1,24 +1,110 @@
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FieldType, SecureNoteType, CipherType } from "@bitwarden/common/vault/enums";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view";
import { ImportResult } from "../../models/import-result";
import { BaseImporter } from "../base-importer";
import { Importer } from "../importer";
import { processNames } from "./protonpass-import-utils";
import {
ProtonPassCreditCardItemContent,
ProtonPassIdentityItemContent,
ProtonPassIdentityItemExtraSection,
ProtonPassItemExtraField,
ProtonPassItemState,
ProtonPassJsonFile,
ProtonPassLoginItemContent,
} from "./types/protonpass-json-type";
export class ProtonPassJsonImporter extends BaseImporter implements Importer {
private mappedIdentityItemKeys = [
"fullName",
"firstName",
"middleName",
"lastName",
"email",
"phoneNumber",
"company",
"socialSecurityNumber",
"passportNumber",
"licenseNumber",
"organization",
"streetAddress",
"floor",
"county",
"city",
"stateOrProvince",
"zipOrPostalCode",
"countryOrRegion",
];
private identityItemExtraFieldsKeys = [
"extraPersonalDetails",
"extraAddressDetails",
"extraContactDetails",
"extraWorkDetails",
"extraSections",
];
constructor(private i18nService: I18nService) {
super();
}
private processIdentityItemUnmappedAndExtraFields(
cipher: CipherView,
identityItem: ProtonPassIdentityItemContent,
) {
Object.keys(identityItem).forEach((key) => {
if (
!this.mappedIdentityItemKeys.includes(key) &&
!this.identityItemExtraFieldsKeys.includes(key)
) {
this.processKvp(
cipher,
key,
identityItem[key as keyof ProtonPassIdentityItemContent] as string,
);
return;
}
if (this.identityItemExtraFieldsKeys.includes(key)) {
if (key !== "extraSections") {
const extraFields = identityItem[
key as keyof ProtonPassIdentityItemContent
] as ProtonPassItemExtraField[];
extraFields?.forEach((extraField) => {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
});
} else {
const extraSections = identityItem[
key as keyof ProtonPassIdentityItemContent
] as ProtonPassIdentityItemExtraSection[];
extraSections?.forEach((extraSection) => {
extraSection.sectionFields?.forEach((extraField) => {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
});
});
}
}
});
}
parse(data: string): Promise<ImportResult> {
const result = new ImportResult();
const results: ProtonPassJsonFile = JSON.parse(data);
@@ -38,7 +124,6 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
if (item.state == ProtonPassItemState.TRASHED) {
continue;
}
this.processFolder(result, vault.name);
const cipher = this.initLoginCipher();
cipher.name = this.getValueOrDefault(item.data.metadata.name, "--");
@@ -96,8 +181,55 @@ export class ProtonPassJsonImporter extends BaseImporter implements Importer {
break;
}
case "identity": {
const identityContent = item.data.content as ProtonPassIdentityItemContent;
cipher.type = CipherType.Identity;
cipher.identity = new IdentityView();
const { mappedFirstName, mappedMiddleName, mappedLastName } = processNames(
this.getValueOrDefault(identityContent.fullName),
this.getValueOrDefault(identityContent.firstName),
this.getValueOrDefault(identityContent.middleName),
this.getValueOrDefault(identityContent.lastName),
);
cipher.identity.firstName = mappedFirstName;
cipher.identity.middleName = mappedMiddleName;
cipher.identity.lastName = mappedLastName;
cipher.identity.email = this.getValueOrDefault(identityContent.email);
cipher.identity.phone = this.getValueOrDefault(identityContent.phoneNumber);
cipher.identity.company = this.getValueOrDefault(identityContent.company);
cipher.identity.ssn = this.getValueOrDefault(identityContent.socialSecurityNumber);
cipher.identity.passportNumber = this.getValueOrDefault(identityContent.passportNumber);
cipher.identity.licenseNumber = this.getValueOrDefault(identityContent.licenseNumber);
const address3 =
`${identityContent.floor ?? ""} ${identityContent.county ?? ""}`.trim();
cipher.identity.address1 = this.getValueOrDefault(identityContent.organization);
cipher.identity.address2 = this.getValueOrDefault(identityContent.streetAddress);
cipher.identity.address3 = this.getValueOrDefault(address3);
cipher.identity.city = this.getValueOrDefault(identityContent.city);
cipher.identity.state = this.getValueOrDefault(identityContent.stateOrProvince);
cipher.identity.postalCode = this.getValueOrDefault(identityContent.zipOrPostalCode);
cipher.identity.country = this.getValueOrDefault(identityContent.countryOrRegion);
this.processIdentityItemUnmappedAndExtraFields(cipher, identityContent);
for (const extraField of item.data.extraFields) {
this.processKvp(
cipher,
extraField.fieldName,
extraField.data.content,
extraField.type === "hidden" ? FieldType.Hidden : FieldType.Text,
);
}
break;
}
default:
continue;
}
this.processFolder(result, vault.name);
this.cleanupCipher(cipher);
result.ciphers.push(cipher);
}

View File

@@ -36,8 +36,11 @@ export type ProtonPassItemData = {
metadata: ProtonPassItemMetadata;
extraFields: ProtonPassItemExtraField[];
platformSpecific?: any;
type: "login" | "alias" | "creditCard" | "note";
content: ProtonPassLoginItemContent | ProtonPassCreditCardItemContent;
type: "login" | "alias" | "creditCard" | "note" | "identity";
content:
| ProtonPassLoginItemContent
| ProtonPassCreditCardItemContent
| ProtonPassIdentityItemContent;
};
export type ProtonPassItemMetadata = {
@@ -74,3 +77,48 @@ export type ProtonPassCreditCardItemContent = {
expirationDate?: string;
pin?: string;
};
export type ProtonPassIdentityItemExtraSection = {
sectionName?: string;
sectionFields?: ProtonPassItemExtraField[];
};
export type ProtonPassIdentityItemContent = {
fullName?: string;
email?: string;
phoneNumber?: string;
firstName?: string;
middleName?: string;
lastName?: string;
birthdate?: string;
gender?: string;
extraPersonalDetails?: ProtonPassItemExtraField[];
organization?: string;
streetAddress?: string;
zipOrPostalCode?: string;
city?: string;
stateOrProvince?: string;
countryOrRegion?: string;
floor?: string;
county?: string;
extraAddressDetails?: ProtonPassItemExtraField[];
socialSecurityNumber?: string;
passportNumber?: string;
licenseNumber?: string;
website?: string;
xHandle?: string;
secondPhoneNumber?: string;
linkedin?: string;
reddit?: string;
facebook?: string;
yahoo?: string;
instagram?: string;
extraContactDetails?: ProtonPassItemExtraField[];
company?: string;
jobTitle?: string;
personalWebsite?: string;
workPhoneNumber?: string;
workEmail?: string;
extraWorkDetails?: ProtonPassItemExtraField[];
extraSections?: ProtonPassIdentityItemExtraSection[];
};

View File

@@ -3,6 +3,7 @@ import { mock, MockProxy } from "jest-mock-extended";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -27,6 +28,7 @@ describe("ImportService", () => {
let i18nService: MockProxy<I18nService>;
let collectionService: MockProxy<CollectionService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let pinService: MockProxy<PinServiceAbstraction>;
let accountService: MockProxy<AccountService>;
@@ -37,6 +39,7 @@ describe("ImportService", () => {
i18nService = mock<I18nService>();
collectionService = mock<CollectionService>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
pinService = mock<PinServiceAbstraction>();
importService = new ImportService(
@@ -46,6 +49,7 @@ describe("ImportService", () => {
i18nService,
collectionService,
cryptoService,
encryptService,
pinService,
accountService,
);

View File

@@ -7,6 +7,7 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque
import { KvpRequest } from "@bitwarden/common/models/request/kvp.request";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -104,6 +105,7 @@ export class ImportService implements ImportServiceAbstraction {
private i18nService: I18nService,
private collectionService: CollectionService,
private cryptoService: CryptoService,
private encryptService: EncryptService,
private pinService: PinServiceAbstraction,
private accountService: AccountService,
) {}
@@ -207,6 +209,7 @@ export class ImportService implements ImportServiceAbstraction {
case "bitwardenpasswordprotected":
return new BitwardenPasswordProtectedImporter(
this.cryptoService,
this.encryptService,
this.i18nService,
this.cipherService,
this.pinService,
@@ -344,9 +347,10 @@ export class ImportService implements ImportServiceAbstraction {
const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId);
request.ciphers.push(new CipherRequest(c));
}
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeUserId);
if (importResult.folders != null) {
for (let i = 0; i < importResult.folders.length; i++) {
const f = await this.folderService.encrypt(importResult.folders[i]);
const f = await this.folderService.encrypt(importResult.folders[i], userKey);
request.folders.push(new FolderWithIdRequest(f));
}
}

View File

@@ -0,0 +1 @@
export * from "./services/browser-service";

View File

@@ -0,0 +1,41 @@
import { isBrowserSafariApi } from "./browser-service";
describe("browser-service", () => {
describe("isBrowserSafariApi", () => {
it("returns true if browser is safari", () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
);
const result = isBrowserSafariApi();
expect(result).toBe(true);
});
it("returns false if browser is chrome", () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
);
const result = isBrowserSafariApi();
expect(result).toBe(false);
});
it("returns false if browser is firefox", () => {
jest
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:130.0) Gecko/20100101 Firefox/130.0",
);
const result = isBrowserSafariApi();
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,7 @@
export function isBrowserSafariApi(): boolean {
return (
navigator.userAgent.indexOf(" Safari/") !== -1 &&
navigator.userAgent.indexOf(" Chrome/") === -1 &&
navigator.userAgent.indexOf(" Chromium/") === -1
);
}

View File

@@ -2,7 +2,7 @@ import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -12,7 +12,7 @@ import { BitwardenCsvExportType, BitwardenPasswordProtectedFileFormat } from "..
export class BaseVaultExportService {
constructor(
protected pinService: PinServiceAbstraction,
protected cryptoService: CryptoService,
protected encryptService: EncryptService,
private cryptoFunctionService: CryptoFunctionService,
private kdfConfigService: KdfConfigService,
) {}
@@ -23,8 +23,8 @@ export class BaseVaultExportService {
const salt = Utils.fromBufferToB64(await this.cryptoFunctionService.randomBytes(16));
const key = await this.pinService.makePinKey(password, salt, kdfConfig);
const encKeyValidation = await this.cryptoService.encrypt(Utils.newGuid(), key);
const encText = await this.cryptoService.encrypt(clearText, key);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), key);
const encText = await this.encryptService.encrypt(clearText, key);
const jsonDoc: BitwardenPasswordProtectedFileFormat = {
encrypted: true,

View File

@@ -1,6 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import {
DEFAULT_KDF_CONFIG,
@@ -9,9 +11,11 @@ import {
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -149,7 +153,9 @@ describe("VaultExportService", () => {
let pinService: MockProxy<PinServiceAbstraction>;
let folderService: MockProxy<FolderService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let kdfConfigService: MockProxy<KdfConfigService>;
let accountService: MockProxy<AccountService>;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();
@@ -157,20 +163,35 @@ describe("VaultExportService", () => {
pinService = mock<PinServiceAbstraction>();
folderService = mock<FolderService>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
kdfConfigService = mock<KdfConfigService>();
accountService = mock<AccountService>();
cryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
const userId = "" as UserId;
const accountInfo: AccountInfo = {
email: "",
emailVerified: true,
name: undefined,
};
const activeAccount = { id: userId, ...accountInfo };
accountService.activeAccount$ = new BehaviorSubject(activeAccount);
folderService.getAllDecryptedFromState.mockResolvedValue(UserFolderViews);
folderService.getAllFromState.mockResolvedValue(UserFolders);
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
cryptoService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
exportService = new IndividualVaultExportService(
folderService,
cipherService,
pinService,
cryptoService,
encryptService,
cryptoFunctionService,
kdfConfigService,
accountService,
);
});
@@ -250,7 +271,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
cryptoService.encrypt.mockResolvedValue(mac);
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);
@@ -258,7 +279,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
cryptoService.encrypt.mockResolvedValue(data);
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);

View File

@@ -1,10 +1,13 @@
import * as papa from "papaparse";
import { firstValueFrom, map } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { CipherWithIdExport, FolderWithIdExport } from "@bitwarden/common/models/export";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
@@ -32,11 +35,13 @@ export class IndividualVaultExportService
private folderService: FolderService,
private cipherService: CipherService,
pinService: PinServiceAbstraction,
cryptoService: CryptoService,
private cryptoService: CryptoService,
encryptService: EncryptService,
cryptoFunctionService: CryptoFunctionService,
kdfConfigService: KdfConfigService,
private accountService: AccountService,
) {
super(pinService, cryptoService, cryptoFunctionService, kdfConfigService);
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
}
async getExport(format: ExportFormat = "csv"): Promise<string> {
@@ -96,7 +101,11 @@ export class IndividualVaultExportService
await Promise.all(promises);
const encKeyValidation = await this.cryptoService.encrypt(Utils.newGuid());
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const userKey = await this.cryptoService.getUserKeyWithLegacySupport(activeUserId);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), userKey);
const jsonDoc: BitwardenEncryptedIndividualJsonExport = {
encrypted: true,

View File

@@ -8,6 +8,7 @@ import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config
import { CipherWithIdExport, CollectionWithIdExport } from "@bitwarden/common/models/export";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
@@ -39,13 +40,14 @@ export class OrganizationVaultExportService
private cipherService: CipherService,
private apiService: ApiService,
pinService: PinServiceAbstraction,
cryptoService: CryptoService,
private cryptoService: CryptoService,
encryptService: EncryptService,
cryptoFunctionService: CryptoFunctionService,
private collectionService: CollectionService,
kdfConfigService: KdfConfigService,
private accountService: AccountService,
) {
super(pinService, cryptoService, cryptoFunctionService, kdfConfigService);
super(pinService, encryptService, cryptoFunctionService, kdfConfigService);
}
async getPasswordProtectedExport(
@@ -242,7 +244,7 @@ export class OrganizationVaultExportService
ciphers: Cipher[],
): Promise<string> {
const orgKey = await this.cryptoService.getOrgKey(organizationId);
const encKeyValidation = await this.cryptoService.encrypt(Utils.newGuid(), orgKey);
const encKeyValidation = await this.encryptService.encrypt(Utils.newGuid(), orgKey);
const jsonDoc: BitwardenEncryptedOrgJsonExport = {
encrypted: true,

View File

@@ -1,6 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import {
DEFAULT_KDF_CONFIG,
@@ -9,9 +11,11 @@ import {
import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KdfType } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EncryptedString, EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -149,6 +153,8 @@ describe("VaultExportService", () => {
let pinService: MockProxy<PinServiceAbstraction>;
let folderService: MockProxy<FolderService>;
let cryptoService: MockProxy<CryptoService>;
let encryptService: MockProxy<EncryptService>;
let accountService: MockProxy<AccountService>;
let kdfConfigService: MockProxy<KdfConfigService>;
beforeEach(() => {
@@ -157,20 +163,34 @@ describe("VaultExportService", () => {
pinService = mock<PinServiceAbstraction>();
folderService = mock<FolderService>();
cryptoService = mock<CryptoService>();
encryptService = mock<EncryptService>();
accountService = mock<AccountService>();
kdfConfigService = mock<KdfConfigService>();
folderService.getAllDecryptedFromState.mockResolvedValue(UserFolderViews);
folderService.getAllFromState.mockResolvedValue(UserFolders);
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
cryptoService.encrypt.mockResolvedValue(new EncString("encrypted"));
encryptService.encrypt.mockResolvedValue(new EncString("encrypted"));
cryptoService.userKey$.mockReturnValue(new BehaviorSubject("mockOriginalUserKey" as any));
const userId = "" as UserId;
const accountInfo: AccountInfo = {
email: "",
emailVerified: true,
name: undefined,
};
const activeAccount = { id: userId, ...accountInfo };
accountService.activeAccount$ = new BehaviorSubject(activeAccount);
exportService = new IndividualVaultExportService(
folderService,
cipherService,
pinService,
cryptoService,
encryptService,
cryptoFunctionService,
kdfConfigService,
accountService,
);
});
@@ -250,7 +270,7 @@ describe("VaultExportService", () => {
});
it("has a mac property", async () => {
cryptoService.encrypt.mockResolvedValue(mac);
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);
@@ -258,7 +278,7 @@ describe("VaultExportService", () => {
});
it("has data property", async () => {
cryptoService.encrypt.mockResolvedValue(data);
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportObject = JSON.parse(exportString);

View File

@@ -3,20 +3,20 @@ import { svgIcon } from "@bitwarden/components";
export const NoCredentialsIcon = svgIcon`
<svg xmlns="http://www.w3.org/2000/svg" width="119" height="114" viewBox="0 0 119 114" fill="none">
<g clip-path="url(#clip0_201_7924)">
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M35.2098 52.2486C35.9068 52.2486 36.4719 52.8137 36.4719 53.5107V58.2685C36.4719 58.9655 35.9068 59.5306 35.2098 59.5306C34.5128 59.5306 33.9478 58.9655 33.9478 58.2685V53.5107C33.9478 52.8137 34.5128 52.2486 35.2098 52.2486Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M40.9963 56.4125C41.2091 57.0762 40.8437 57.7868 40.18 57.9997L35.5951 59.4703C34.9314 59.6832 34.2208 59.3177 34.0079 58.654C33.795 57.9903 34.1605 57.2797 34.8242 57.0668L39.409 55.5962C40.0727 55.3833 40.7834 55.7487 40.9963 56.4125Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M34.471 57.2455C35.036 56.8374 35.8249 56.9647 36.233 57.5297L39.0445 61.4225C39.4526 61.9876 39.3254 62.7765 38.7603 63.1846C38.1952 63.5927 37.4063 63.4654 36.9982 62.9004L34.1868 59.0076C33.7787 58.4425 33.9059 57.6536 34.471 57.2455Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M35.94 57.2401C36.508 57.6441 36.6411 58.432 36.2371 59.0001L33.4689 62.8928C33.065 63.4609 32.277 63.5939 31.709 63.19C31.141 62.786 31.0079 61.9981 31.4119 61.43L34.1801 57.5373C34.584 56.9692 35.3719 56.8362 35.94 57.2401Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M29.4665 56.4091C29.6812 55.746 30.3929 55.3825 31.056 55.5972L35.5976 57.0679C36.2607 57.2826 36.6242 57.9942 36.4095 58.6573C36.1947 59.3205 35.4831 59.684 34.82 59.4692L30.2784 57.9986C29.6153 57.7839 29.2518 57.0723 29.4665 56.4091Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M50.6932 52.2487C51.3902 52.2487 51.9553 52.8137 51.9553 53.5107V58.2686C51.9553 58.9656 51.3902 59.5306 50.6932 59.5306C49.9962 59.5306 49.4312 58.9656 49.4312 58.2686V53.5107C49.4312 52.8137 49.9962 52.2487 50.6932 52.2487Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M56.4353 56.4088C56.6501 57.072 56.2866 57.7836 55.6234 57.9983L51.0819 59.4689C50.4187 59.6837 49.7071 59.3202 49.4924 58.657C49.2777 57.9939 49.6412 57.2823 50.3043 57.0676L54.8458 55.5969C55.509 55.3822 56.2206 55.7457 56.4353 56.4088Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M49.9544 57.2452C50.5194 56.8371 51.3083 56.9643 51.7164 57.5294L54.5279 61.4221C54.936 61.9872 54.8087 62.7761 54.2437 63.1842C53.6786 63.5923 52.8897 63.4651 52.4816 62.9L49.6702 59.0072C49.2621 58.4422 49.3893 57.6533 49.9544 57.2452Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M51.4331 57.2452C51.9982 57.6533 52.1254 58.4422 51.7173 59.0072L48.9059 62.9C48.4978 63.4651 47.7089 63.5923 47.1438 63.1842C46.5788 62.7761 46.4515 61.9872 46.8596 61.4221L49.6711 57.5294C50.0792 56.9643 50.8681 56.8371 51.4331 57.2452Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M44.9514 56.4088C45.1661 55.7457 45.8777 55.3822 46.5409 55.5969L51.0824 57.0676C51.7455 57.2823 52.109 57.9939 51.8943 58.657C51.6796 59.3202 50.968 59.6837 50.3048 59.4689L45.7633 57.9983C45.1001 57.7836 44.7366 57.072 44.9514 56.4088Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M60.5229 62.3772C60.5229 61.6802 61.088 61.1151 61.785 61.1151H70.7935C71.4905 61.1151 72.0556 61.6802 72.0556 62.3772C72.0556 63.0742 71.4905 63.6392 70.7935 63.6392H61.785C61.088 63.6392 60.5229 63.0742 60.5229 62.3772Z" />
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M75.9663 62.3772C75.9663 61.6802 76.5314 61.1151 77.2284 61.1151H86.2369C86.9339 61.1151 87.4989 61.6802 87.4989 62.3772C87.4989 63.0742 86.9339 63.6392 86.2369 63.6392H77.2284C76.5314 63.6392 75.9663 63.0742 75.9663 62.3772Z" />
<path fill="#15C0CB" fill-rule="evenodd" clip-rule="evenodd" d="M20.1396 57.9313C20.1396 50.6126 26.0726 44.6796 33.3914 44.6796H86.3982C93.7169 44.6796 99.6499 50.6126 99.6499 57.9313C99.6499 65.25 93.7169 71.183 86.3982 71.183H33.3914C26.0726 71.183 20.1396 65.25 20.1396 57.9313ZM33.3914 47.2037C27.4667 47.2037 22.6638 52.0066 22.6638 57.9313C22.6638 63.856 27.4667 68.6589 33.3914 68.6589H86.3982C92.3229 68.6589 97.1258 63.856 97.1258 57.9313C97.1258 52.0066 92.3229 47.2037 86.3982 47.2037H33.3914Z"/>
<path fill="#020F66" fill-rule="evenodd" clip-rule="evenodd" d="M40.8279 11.8469C41.4764 12.1023 41.7952 12.835 41.5398 13.4836L37.3784 24.0525C37.123 24.701 36.3902 25.0198 35.7417 24.7644C35.0931 24.509 34.7744 23.7762 35.0298 23.1277L38.0204 15.5323C35.2016 16.9889 32.4865 18.7508 29.92 20.8232C9.44808 37.3546 6.25361 67.3517 22.785 87.8236C27.3496 93.4763 32.9382 97.8098 39.0683 100.775C39.6957 101.079 39.9583 101.834 39.6547 102.461C39.3512 103.089 38.5964 103.351 37.969 103.048C31.5107 99.9231 25.6247 95.3579 20.8212 89.4094C3.414 67.8529 6.77771 36.2666 28.3342 18.8594C31.1318 16.6003 34.0994 14.6905 37.1838 13.1248L29.3343 10.0341C28.6857 9.77875 28.367 9.04598 28.6223 8.39742C28.8777 7.74886 29.6105 7.43012 30.259 7.68548L40.8279 11.8469ZM84.1129 15.392C84.4739 14.7958 85.2499 14.6051 85.8462 14.9661C90.6935 17.901 95.1212 21.7125 98.8842 26.3725C116.291 47.929 112.928 79.5153 91.3711 96.9224C90.3117 97.7779 89.2278 98.5834 88.1224 99.339L96.3064 101.382C96.9827 101.551 97.394 102.236 97.2252 102.912C97.0564 103.588 96.3713 104 95.6951 103.831L84.6746 101.08C83.9984 100.911 83.587 100.226 83.7558 99.5498L86.5067 88.5294C86.6755 87.8531 87.3606 87.4417 88.0368 87.6105C88.7131 87.7794 89.1245 88.4644 88.9557 89.1407L86.9784 97.0621C87.9316 96.4005 88.8679 95.6994 89.7853 94.9586C110.257 78.4273 113.452 48.4302 96.9203 27.9583C93.3439 23.5293 89.1393 19.9108 84.5388 17.1253C83.9426 16.7643 83.7519 15.9883 84.1129 15.392Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M35.2098 52.2486C35.9068 52.2486 36.4719 52.8137 36.4719 53.5107V58.2685C36.4719 58.9655 35.9068 59.5306 35.2098 59.5306C34.5128 59.5306 33.9478 58.9655 33.9478 58.2685V53.5107C33.9478 52.8137 34.5128 52.2486 35.2098 52.2486Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M40.9963 56.4125C41.2091 57.0762 40.8437 57.7868 40.18 57.9997L35.5951 59.4703C34.9314 59.6832 34.2208 59.3177 34.0079 58.654C33.795 57.9903 34.1605 57.2797 34.8242 57.0668L39.409 55.5962C40.0727 55.3833 40.7834 55.7487 40.9963 56.4125Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M34.471 57.2455C35.036 56.8374 35.8249 56.9647 36.233 57.5297L39.0445 61.4225C39.4526 61.9876 39.3254 62.7765 38.7603 63.1846C38.1952 63.5927 37.4063 63.4654 36.9982 62.9004L34.1868 59.0076C33.7787 58.4425 33.9059 57.6536 34.471 57.2455Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M35.94 57.2401C36.508 57.6441 36.6411 58.432 36.2371 59.0001L33.4689 62.8928C33.065 63.4609 32.277 63.5939 31.709 63.19C31.141 62.786 31.0079 61.9981 31.4119 61.43L34.1801 57.5373C34.584 56.9692 35.3719 56.8362 35.94 57.2401Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M29.4665 56.4091C29.6812 55.746 30.3929 55.3825 31.056 55.5972L35.5976 57.0679C36.2607 57.2826 36.6242 57.9942 36.4095 58.6573C36.1947 59.3205 35.4831 59.684 34.82 59.4692L30.2784 57.9986C29.6153 57.7839 29.2518 57.0723 29.4665 56.4091Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M50.6932 52.2487C51.3902 52.2487 51.9553 52.8137 51.9553 53.5107V58.2686C51.9553 58.9656 51.3902 59.5306 50.6932 59.5306C49.9962 59.5306 49.4312 58.9656 49.4312 58.2686V53.5107C49.4312 52.8137 49.9962 52.2487 50.6932 52.2487Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M56.4353 56.4088C56.6501 57.072 56.2866 57.7836 55.6234 57.9983L51.0819 59.4689C50.4187 59.6837 49.7071 59.3202 49.4924 58.657C49.2777 57.9939 49.6412 57.2823 50.3043 57.0676L54.8458 55.5969C55.509 55.3822 56.2206 55.7457 56.4353 56.4088Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M49.9544 57.2452C50.5194 56.8371 51.3083 56.9643 51.7164 57.5294L54.5279 61.4221C54.936 61.9872 54.8087 62.7761 54.2437 63.1842C53.6786 63.5923 52.8897 63.4651 52.4816 62.9L49.6702 59.0072C49.2621 58.4422 49.3893 57.6533 49.9544 57.2452Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M51.4331 57.2452C51.9982 57.6533 52.1254 58.4422 51.7173 59.0072L48.9059 62.9C48.4978 63.4651 47.7089 63.5923 47.1438 63.1842C46.5788 62.7761 46.4515 61.9872 46.8596 61.4221L49.6711 57.5294C50.0792 56.9643 50.8681 56.8371 51.4331 57.2452Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M44.9514 56.4088C45.1661 55.7457 45.8777 55.3822 46.5409 55.5969L51.0824 57.0676C51.7455 57.2823 52.109 57.9939 51.8943 58.657C51.6796 59.3202 50.968 59.6837 50.3048 59.4689L45.7633 57.9983C45.1001 57.7836 44.7366 57.072 44.9514 56.4088Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M60.5229 62.3772C60.5229 61.6802 61.088 61.1151 61.785 61.1151H70.7935C71.4905 61.1151 72.0556 61.6802 72.0556 62.3772C72.0556 63.0742 71.4905 63.6392 70.7935 63.6392H61.785C61.088 63.6392 60.5229 63.0742 60.5229 62.3772Z" />
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M75.9663 62.3772C75.9663 61.6802 76.5314 61.1151 77.2284 61.1151H86.2369C86.9339 61.1151 87.4989 61.6802 87.4989 62.3772C87.4989 63.0742 86.9339 63.6392 86.2369 63.6392H77.2284C76.5314 63.6392 75.9663 63.0742 75.9663 62.3772Z" />
<path class="tw-fill-art-accent" fill-rule="evenodd" clip-rule="evenodd" d="M20.1396 57.9313C20.1396 50.6126 26.0726 44.6796 33.3914 44.6796H86.3982C93.7169 44.6796 99.6499 50.6126 99.6499 57.9313C99.6499 65.25 93.7169 71.183 86.3982 71.183H33.3914C26.0726 71.183 20.1396 65.25 20.1396 57.9313ZM33.3914 47.2037C27.4667 47.2037 22.6638 52.0066 22.6638 57.9313C22.6638 63.856 27.4667 68.6589 33.3914 68.6589H86.3982C92.3229 68.6589 97.1258 63.856 97.1258 57.9313C97.1258 52.0066 92.3229 47.2037 86.3982 47.2037H33.3914Z"/>
<path class="tw-fill-art-primary" fill-rule="evenodd" clip-rule="evenodd" d="M40.8279 11.8469C41.4764 12.1023 41.7952 12.835 41.5398 13.4836L37.3784 24.0525C37.123 24.701 36.3902 25.0198 35.7417 24.7644C35.0931 24.509 34.7744 23.7762 35.0298 23.1277L38.0204 15.5323C35.2016 16.9889 32.4865 18.7508 29.92 20.8232C9.44808 37.3546 6.25361 67.3517 22.785 87.8236C27.3496 93.4763 32.9382 97.8098 39.0683 100.775C39.6957 101.079 39.9583 101.834 39.6547 102.461C39.3512 103.089 38.5964 103.351 37.969 103.048C31.5107 99.9231 25.6247 95.3579 20.8212 89.4094C3.414 67.8529 6.77771 36.2666 28.3342 18.8594C31.1318 16.6003 34.0994 14.6905 37.1838 13.1248L29.3343 10.0341C28.6857 9.77875 28.367 9.04598 28.6223 8.39742C28.8777 7.74886 29.6105 7.43012 30.259 7.68548L40.8279 11.8469ZM84.1129 15.392C84.4739 14.7958 85.2499 14.6051 85.8462 14.9661C90.6935 17.901 95.1212 21.7125 98.8842 26.3725C116.291 47.929 112.928 79.5153 91.3711 96.9224C90.3117 97.7779 89.2278 98.5834 88.1224 99.339L96.3064 101.382C96.9827 101.551 97.394 102.236 97.2252 102.912C97.0564 103.588 96.3713 104 95.6951 103.831L84.6746 101.08C83.9984 100.911 83.587 100.226 83.7558 99.5498L86.5067 88.5294C86.6755 87.8531 87.3606 87.4417 88.0368 87.6105C88.7131 87.7794 89.1245 88.4644 88.9557 89.1407L86.9784 97.0621C87.9316 96.4005 88.8679 95.6994 89.7853 94.9586C110.257 78.4273 113.452 48.4302 96.9203 27.9583C93.3439 23.5293 89.1393 19.9108 84.5388 17.1253C83.9426 16.7643 83.7519 15.9883 84.1129 15.392Z" />
</g>
<defs>
<clipPath id="clip0_201_7924">

View File

@@ -5,7 +5,7 @@
<form class="box" [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "numWords" | i18n }}</bit-label>
<input
bitInput
@@ -28,10 +28,11 @@
<input bitCheckbox formControlName="capitalize" id="capitalize" type="checkbox" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox formControlName="includeNumber" id="include-number" type="checkbox" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
</bit-card>
</div>
</form>

View File

@@ -23,7 +23,7 @@ const Controls = Object.freeze({
/** Options group for passphrases */
@Component({
standalone: true,
selector: "bit-passphrase-settings",
selector: "tools-passphrase-settings",
templateUrl: "passphrase-settings.component.html",
imports: [DependenciesModule],
})
@@ -81,24 +81,22 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
this.generatorService
.policy$(Generators.Passphrase, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe((policy) => {
.subscribe(({ constraints }) => {
this.settings
.get(Controls.numWords)
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, policy));
.setValidators(toValidators(Controls.numWords, Generators.Passphrase, constraints));
this.settings
.get(Controls.wordSeparator)
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, policy));
.setValidators(toValidators(Controls.wordSeparator, Generators.Passphrase, constraints));
// forward word boundaries to the template (can't do it through the rx form)
// FIXME: move the boundary logic fully into the policy evaluator
this.minNumWords =
policy.numWords?.min ?? Generators.Passphrase.settings.constraints.numWords.min;
this.maxNumWords =
policy.numWords?.max ?? Generators.Passphrase.settings.constraints.numWords.max;
this.minNumWords = constraints.numWords.min;
this.maxNumWords = constraints.numWords.max;
this.policyInEffect = constraints.policyInEffect;
this.toggleEnabled(Controls.capitalize, !policy.policy.capitalize);
this.toggleEnabled(Controls.includeNumber, !policy.policy.includeNumber);
this.toggleEnabled(Controls.capitalize, !constraints.capitalize?.readonly);
this.toggleEnabled(Controls.includeNumber, !constraints.includeNumber?.readonly);
});
// now that outputs are set up, connect inputs
@@ -111,11 +109,14 @@ export class PassphraseSettingsComponent implements OnInit, OnDestroy {
/** attribute binding for numWords[max] */
protected maxNumWords: number;
/** display binding for enterprise policy notice */
protected policyInEffect: boolean;
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
if (enabled) {
this.settings.get(setting).enable();
this.settings.get(setting).enable({ emitEvent: false });
} else {
this.settings.get(setting).disable();
this.settings.get(setting).disable({ emitEvent: false });
}
}

View File

@@ -13,10 +13,10 @@
</bit-toggle>
</bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow">
<div class="tw-grow tw-flex tw-items-center">
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
</div>
<div class="tw-space-x-1 tw-flex-none tw-w-4">
<div class="tw-space-x-1">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
@@ -30,13 +30,13 @@
</button>
</div>
</bit-card>
<bit-password-settings
<tools-password-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'password'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<bit-passphrase-settings
<tools-passphrase-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'passphrase'"
[userId]="this.userId$ | async"

View File

@@ -13,7 +13,7 @@ import { PasswordSettingsComponent } from "./password-settings.component";
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-generator",
selector: "tools-password-generator",
templateUrl: "password-generator.component.html",
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
})

View File

@@ -1,11 +1,11 @@
<bit-section>
<bit-section-header *ngIf="showHeader">
<h5 bitTypography="h5">{{ "options" | i18n }}</h5>
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
</bit-section-header>
<form class="box" [formGroup]="settings" class="tw-container">
<div class="tw-mb-4">
<bit-card>
<bit-form-field>
<bit-form-field disableMargin>
<bit-label>{{ "length" | i18n }}</bit-label>
<input
bitInput
@@ -42,7 +42,7 @@
attr.aria-description="{{ 'numbersDescription' | i18n }}"
title="{{ 'numbersDescription' | i18n }}"
>
<input bitCheckbox type="checkbox" formControlName="numbers" />
<input bitCheckbox type="checkbox" formControlName="number" />
<bit-label>{{ "numbersLabel" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control
@@ -76,10 +76,11 @@
/>
</bit-form-field>
</div>
<bit-form-control>
<bit-form-control [disableMargin]="!policyInEffect">
<input bitCheckbox type="checkbox" formControlName="avoidAmbiguous" />
<bit-label>{{ "avoidAmbiguous" | i18n }}</bit-label>
</bit-form-control>
<p *ngIf="policyInEffect" bitTypography="helper">{{ "generatorPolicyInEffect" | i18n }}</p>
</bit-card>
</div>
</form>

View File

@@ -1,6 +1,6 @@
import { OnInit, Input, Output, EventEmitter, Component, OnDestroy } from "@angular/core";
import { FormBuilder } from "@angular/forms";
import { BehaviorSubject, skip, takeUntil, Subject, map } from "rxjs";
import { BehaviorSubject, takeUntil, Subject, map, filter, tap, debounceTime, skip } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
@@ -17,7 +17,7 @@ const Controls = Object.freeze({
length: "length",
uppercase: "uppercase",
lowercase: "lowercase",
numbers: "numbers",
number: "number",
special: "special",
minNumber: "minNumber",
minSpecial: "minSpecial",
@@ -27,7 +27,7 @@ const Controls = Object.freeze({
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-settings",
selector: "tools-password-settings",
templateUrl: "password-settings.component.html",
imports: [DependenciesModule],
})
@@ -54,6 +54,10 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
@Input()
showHeader: boolean = true;
/** Number of milliseconds to wait before accepting user input. */
@Input()
waitMs: number = 100;
/** Emits settings updates and completes if the settings become unavailable.
* @remarks this does not emit the initial settings. If you would like
* to receive live settings updates including the initial update,
@@ -66,17 +70,34 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
[Controls.length]: [Generators.Password.settings.initial.length],
[Controls.uppercase]: [Generators.Password.settings.initial.uppercase],
[Controls.lowercase]: [Generators.Password.settings.initial.lowercase],
[Controls.numbers]: [Generators.Password.settings.initial.number],
[Controls.number]: [Generators.Password.settings.initial.number],
[Controls.special]: [Generators.Password.settings.initial.special],
[Controls.minNumber]: [Generators.Password.settings.initial.minNumber],
[Controls.minSpecial]: [Generators.Password.settings.initial.minSpecial],
[Controls.avoidAmbiguous]: [!Generators.Password.settings.initial.ambiguous],
});
private get numbers() {
return this.settings.get(Controls.number);
}
private get special() {
return this.settings.get(Controls.special);
}
private get minNumber() {
return this.settings.get(Controls.minNumber);
}
private get minSpecial() {
return this.settings.get(Controls.minSpecial);
}
async ngOnInit() {
const singleUserId$ = this.singleUserId$();
const settings = await this.generatorService.settings(Generators.Password, { singleUserId$ });
// bind settings to the UI
settings
.pipe(
map((settings) => {
@@ -93,47 +114,41 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
this.settings.patchValue(s, { emitEvent: false });
});
// the first emission is the current value; subsequent emissions are updates
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
///
// bind policy to the template
this.generatorService
.policy$(Generators.Password, { userId$: singleUserId$ })
.pipe(takeUntil(this.destroyed$))
.subscribe((policy) => {
.subscribe(({ constraints }) => {
this.settings
.get(Controls.length)
.setValidators(toValidators(Controls.length, Generators.Password, policy));
.setValidators(toValidators(Controls.length, Generators.Password, constraints));
this.settings
.get(Controls.minNumber)
.setValidators(toValidators(Controls.minNumber, Generators.Password, policy));
this.minNumber.setValidators(
toValidators(Controls.minNumber, Generators.Password, constraints),
);
this.settings
.get(Controls.minSpecial)
.setValidators(toValidators(Controls.minSpecial, Generators.Password, policy));
this.minSpecial.setValidators(
toValidators(Controls.minSpecial, Generators.Password, constraints),
);
// forward word boundaries to the template (can't do it through the rx form)
// FIXME: move the boundary logic fully into the policy evaluator
this.minLength = policy.length?.min ?? Generators.Password.settings.constraints.length.min;
this.maxLength = policy.length?.max ?? Generators.Password.settings.constraints.length.max;
this.minMinNumber =
policy.minNumber?.min ?? Generators.Password.settings.constraints.minNumber.min;
this.maxMinNumber =
policy.minNumber?.max ?? Generators.Password.settings.constraints.minNumber.max;
this.minMinSpecial =
policy.minSpecial?.min ?? Generators.Password.settings.constraints.minSpecial.min;
this.maxMinSpecial =
policy.minSpecial?.max ?? Generators.Password.settings.constraints.minSpecial.max;
this.minLength = constraints.length.min;
this.maxLength = constraints.length.max;
this.minMinNumber = constraints.minNumber.min;
this.maxMinNumber = constraints.minNumber.max;
this.minMinSpecial = constraints.minSpecial.min;
this.maxMinSpecial = constraints.minSpecial.max;
this.policyInEffect = constraints.policyInEffect;
const toggles = [
[Controls.length, policy.length.min < policy.length.max],
[Controls.uppercase, !policy.policy.useUppercase],
[Controls.lowercase, !policy.policy.useLowercase],
[Controls.numbers, !policy.policy.useNumbers],
[Controls.special, !policy.policy.useSpecial],
[Controls.minNumber, policy.minNumber.min < policy.minNumber.max],
[Controls.minSpecial, policy.minSpecial.min < policy.minSpecial.max],
[Controls.length, constraints.length.min < constraints.length.max],
[Controls.uppercase, !constraints.uppercase?.readonly],
[Controls.lowercase, !constraints.lowercase?.readonly],
[Controls.number, !constraints.number?.readonly],
[Controls.special, !constraints.special?.readonly],
[Controls.minNumber, constraints.minNumber.min < constraints.minNumber.max],
[Controls.minSpecial, constraints.minSpecial.min < constraints.minSpecial.max],
] as [keyof typeof Controls, boolean][];
for (const [control, enabled] of toggles) {
@@ -141,9 +156,53 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
}
});
// cascade selections between checkboxes and spinboxes
// before the group saves their values
let lastMinNumber = 1;
this.numbers.valueChanges
.pipe(
filter((checked) => !(checked && this.minNumber.value > 0)),
map((checked) => (checked ? lastMinNumber : 0)),
takeUntil(this.destroyed$),
)
.subscribe((value) => this.minNumber.setValue(value, { emitEvent: false }));
this.minNumber.valueChanges
.pipe(
map((value) => [value, value > 0] as const),
tap(([value]) => (lastMinNumber = this.numbers.value ? value : lastMinNumber)),
takeUntil(this.destroyed$),
)
.subscribe(([, checked]) => this.numbers.setValue(checked, { emitEvent: false }));
let lastMinSpecial = 1;
this.special.valueChanges
.pipe(
filter((checked) => !(checked && this.minSpecial.value > 0)),
map((checked) => (checked ? lastMinSpecial : 0)),
takeUntil(this.destroyed$),
)
.subscribe((value) => this.minSpecial.setValue(value, { emitEvent: false }));
this.minSpecial.valueChanges
.pipe(
map((value) => [value, value > 0] as const),
tap(([value]) => (lastMinSpecial = this.special.value ? value : lastMinSpecial)),
takeUntil(this.destroyed$),
)
.subscribe(([, checked]) => this.special.setValue(checked, { emitEvent: false }));
// `onUpdated` depends on `settings` because the UserStateSubject is asynchronous;
// subscribing directly to `this.settings.valueChanges` introduces a race condition.
// skip the first emission because it's the initial value, not an update.
settings.pipe(skip(1), takeUntil(this.destroyed$)).subscribe(this.onUpdated);
// now that outputs are set up, connect inputs
this.settings.valueChanges
.pipe(
// debounce ensures rapid edits to a field, such as partial edits to a
// spinbox or rapid button clicks don't emit spurious generator updates
debounceTime(this.waitMs),
map((settings) => {
// interface is "avoid" while storage is "include"
const s: any = { ...settings };
@@ -174,11 +233,14 @@ export class PasswordSettingsComponent implements OnInit, OnDestroy {
/** attribute binding for minSpecial[max] */
protected maxMinSpecial: number;
/** display binding for enterprise policy notice */
protected policyInEffect: boolean;
private toggleEnabled(setting: keyof typeof Controls, enabled: boolean) {
if (enabled) {
this.settings.get(setting).enable();
this.settings.get(setting).enable({ emitEvent: false });
} else {
this.settings.get(setting).disable();
this.settings.get(setting).disable({ emitEvent: false });
}
}

View File

@@ -1,5 +1,5 @@
import { ValidatorFn, Validators } from "@angular/forms";
import { map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs";
import { distinctUntilChanged, map, pairwise, pipe, skipWhile, startWith, takeWhile } from "rxjs";
import { AnyConstraint, Constraints } from "@bitwarden/common/tools/types";
import { UserId } from "@bitwarden/common/types/guid";
@@ -13,6 +13,7 @@ export function completeOnAccountSwitch() {
pairwise(),
takeWhile(([prev, next]) => (prev ?? next) === next),
map(([_, id]) => id),
distinctUntilChanged(),
);
}

View File

@@ -1,9 +1,10 @@
import { PasswordGenerationOptions } from "../types";
import { PasswordGenerationOptions, PasswordGeneratorSettings } from "../types";
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
/** The default options for password generation. */
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> &
PasswordGeneratorSettings = Object.freeze({
length: 14,
minLength: DefaultPasswordBoundaries.length.min,
ambiguous: true,

View File

@@ -1,9 +1,11 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import {
DynamicPasswordPolicyConstraints,
passphraseLeastPrivilege,
passwordLeastPrivilege,
PassphraseGeneratorOptionsEvaluator,
PassphrasePolicyConstraints,
PasswordGeneratorOptionsEvaluator,
} from "../policies";
import {
@@ -23,7 +25,7 @@ const PASSPHRASE = Object.freeze({
}),
combine: passphraseLeastPrivilege,
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
createEvaluatorV2: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
toConstraints: (policy) => new PassphrasePolicyConstraints(policy),
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGenerationOptions>);
const PASSWORD = Object.freeze({
@@ -39,7 +41,7 @@ const PASSWORD = Object.freeze({
}),
combine: passwordLeastPrivilege,
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
createEvaluatorV2: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
toConstraints: (policy) => new DynamicPasswordPolicyConstraints(policy),
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGenerationOptions>);
/** Policy configurations */

View File

@@ -0,0 +1,280 @@
import { Constraint } from "@bitwarden/common/tools/types";
import {
atLeast,
atLeastSum,
maybe,
maybeReadonly,
fitToBounds,
enforceConstant,
fitLength,
readonlyTrueWhen,
RequiresTrue,
} from "./constraints";
const SomeBooleanConstraint: Constraint<boolean> = Object.freeze({});
describe("password generator constraint utilities", () => {
describe("atLeast", () => {
it("creates a minimum constraint when constraint is undefined", () => {
const result = atLeast(1);
expect(result).toEqual({ min: 1 });
});
it("returns the constraint when minimum is undefined", () => {
const constraint = {};
const result = atLeast(undefined, constraint);
expect(result).toBe(constraint);
});
it("adds a minimum member to a constraint", () => {
const result = atLeast(1, {});
expect(result).toEqual({ min: 1 });
});
it("adjusts the minimum member of a constraint to the minimum value", () => {
const result = atLeast(2, { min: 1 });
expect(result).toEqual({ min: 2 });
});
it("adjusts the maximum member of a constraint to the minimum value", () => {
const result = atLeast(2, { min: 0, max: 1 });
expect(result).toEqual({ min: 2, max: 2 });
});
it("copies the constraint", () => {
const constraint = { min: 1, step: 1 };
const result = atLeast(1, constraint);
expect(result).not.toBe(constraint);
expect(result).toEqual({ min: 1, step: 1 });
});
});
describe("atLeastSum", () => {
it("creates a minimum constraint", () => {
const result = atLeastSum(undefined, []);
expect(result).toEqual({ min: 0 });
});
it("creates a minimum constraint that is the sum of the dependencies' minimums", () => {
const result = atLeastSum(undefined, [{ min: 1 }, { min: 1 }]);
expect(result).toEqual({ min: 2 });
});
it("adds a minimum member to a constraint", () => {
const result = atLeastSum({}, []);
expect(result).toEqual({ min: 0 });
});
it("adjusts the minimum member of a constraint to the minimum sum", () => {
const result = atLeastSum({ min: 0 }, [{ min: 1 }]);
expect(result).toEqual({ min: 1 });
});
it("adjusts the maximum member of a constraint to the minimum sum", () => {
const result = atLeastSum({ min: 0, max: 1 }, [{ min: 2 }]);
expect(result).toEqual({ min: 2, max: 2 });
});
it("copies the constraint", () => {
const constraint = { step: 1 };
const result = atLeastSum(constraint, []);
expect(result).not.toBe(constraint);
expect(result).toEqual({ min: 0, step: 1 });
});
});
describe("maybe", () => {
it("returns the constraint when it is enabled", () => {
const result = maybe(true, SomeBooleanConstraint);
expect(result).toBe(SomeBooleanConstraint);
});
it("returns undefined when the constraint is disabled", () => {
const result = maybe(false, SomeBooleanConstraint);
expect(result).toBeUndefined();
});
});
describe("maybeReadonly", () => {
it("returns the constraint when readonly is false", () => {
const result = maybeReadonly(false, SomeBooleanConstraint);
expect(result).toBe(SomeBooleanConstraint);
});
it("adds a readonly member when readonly is true", () => {
const result = maybeReadonly(true, SomeBooleanConstraint);
expect(result).toMatchObject({ readonly: true });
});
it("copies the constraint when readonly is true", () => {
const result = maybeReadonly(true, { requiredValue: true });
expect(result).not.toBe(SomeBooleanConstraint);
expect(result).toMatchObject({ readonly: true, requiredValue: true });
});
it("crates a readonly constraint when the input is undefined", () => {
const result = maybeReadonly(true);
expect(result).not.toBe(SomeBooleanConstraint);
expect(result).toEqual({ readonly: true });
});
});
describe("fitToBounds", () => {
it("returns the value when the constraint is undefined", () => {
const result = fitToBounds(1, undefined);
expect(result).toEqual(1);
});
it("applies the maximum bound", () => {
const result = fitToBounds(2, { max: 1 });
expect(result).toEqual(1);
});
it("applies the minimum bound", () => {
const result = fitToBounds(0, { min: 1 });
expect(result).toEqual(1);
});
it.each([[0], [1]])(
"returns 0 when value is undefined and 0 <= the maximum bound (= %p)",
(max) => {
const result = fitToBounds(undefined, { max });
expect(result).toEqual(0);
},
);
it.each([[0], [-1]])(
"returns 0 when value is undefined and 0 >= the minimum bound (= %p)",
(min) => {
const result = fitToBounds(undefined, { min });
expect(result).toEqual(0);
},
);
it("returns the maximum bound when value is undefined and 0 > the maximum bound", () => {
const result = fitToBounds(undefined, { max: -1 });
expect(result).toEqual(-1);
});
it("returns the minimum bound when value is undefined and 0 < the minimum bound", () => {
const result = fitToBounds(undefined, { min: 1 });
expect(result).toEqual(1);
});
});
describe("fitLength", () => {
it("returns the value when the constraint is undefined", () => {
const result = fitLength("someValue", undefined);
expect(result).toEqual("someValue");
});
it.each([[null], [undefined]])(
"returns an empty string when the value is nullish (= %p)",
(value: string) => {
const result = fitLength(value, {});
expect(result).toEqual("");
},
);
it("applies the maxLength bound", () => {
const result = fitLength("some value", { maxLength: 4 });
expect(result).toEqual("some");
});
it("applies the minLength bound", () => {
const result = fitLength("some", { minLength: 5 });
expect(result).toEqual("some ");
});
it("fills characters from the fillString", () => {
const result = fitLength("some", { minLength: 10 }, { fillString: " value" });
expect(result).toEqual("some value");
});
it("repeats characters from the fillString", () => {
const result = fitLength("i", { minLength: 3 }, { fillString: "+" });
expect(result).toEqual("i++");
});
});
describe("enforceConstant", () => {
it("returns the requiredValue member from a readonly constraint", () => {
const result = enforceConstant(false, { readonly: true, requiredValue: true });
expect(result).toBeTruthy();
});
it("returns undefined from a readonly constraint without a required value", () => {
const result = enforceConstant(false, { readonly: true });
expect(result).toBeUndefined();
});
it.each([[{}], [{ readonly: false }]])(
"returns value when the constraint is writable (= %p)",
(constraint) => {
const result = enforceConstant(false, constraint);
expect(result).toBeFalsy();
},
);
it("returns value when the constraint is undefined", () => {
const result = enforceConstant(false, undefined);
expect(result).toBeFalsy();
});
});
describe("readonlyTrueWhen", () => {
it.each([[false], [null], [undefined]])(
"returns undefined when enabled is falsy (= %p)",
(value) => {
const result = readonlyTrueWhen(value);
expect(result).toBeUndefined();
},
);
it("returns a readonly RequiresTrue when enabled is true", () => {
const result = readonlyTrueWhen(true);
expect(result).toMatchObject({ readonly: true });
expect(result).toMatchObject(RequiresTrue);
});
});
});

View File

@@ -0,0 +1,164 @@
import { Constraint } from "@bitwarden/common/tools/types";
import { sum } from "../util";
const AtLeastOne: Constraint<number> = { min: 1 };
const RequiresTrue: Constraint<boolean> = { requiredValue: true };
/** Ensures the minimum and maximum bounds of a constraint are at least as large as the
* combined minimum bounds of `dependencies`.
* @param current the constraint extended by the combinator.
* @param dependencies the constraints summed to determine the bounds of `current`.
* @returns a copy of `current` with the new bounds applied.
*
*/
function atLeastSum(current: Constraint<number>, dependencies: Constraint<number>[]) {
// length must be at least as long as the required character set
const minConsistentLength = sum(...dependencies.map((c) => c?.min));
const minLength = Math.max(current?.min ?? 0, minConsistentLength);
const length = atLeast(minLength, current);
return length;
}
/** Extends a constraint with a readonly field.
* @param readonly Adds a readonly field when this is `true`.
* @param constraint the constraint extended by the combinator.
* @returns a copy of `constraint` with the readonly constraint applied as-needed.
*/
function maybeReadonly(readonly: boolean, constraint?: Constraint<boolean>): Constraint<boolean> {
if (!readonly) {
return constraint;
}
const result: Constraint<boolean> = Object.assign({}, constraint ?? {});
result.readonly = true;
return result;
}
/** Conditionally enables a constraint.
* @param enabled the condition to evaluate
* @param constraint the condition to conditionally enable
* @returns `constraint` when `enabled` is true. Otherwise returns `undefined.
*/
function maybe<T>(enabled: boolean, constraint: Constraint<T>): Constraint<T> {
return enabled ? constraint : undefined;
}
// copies `constraint`; ensures both bounds >= value
/** Ensures the boundaries of a constraint are at least equal to the minimum.
* @param minimum the lower bound of the constraint. When this is `undefined` or `null`,
* the method returns `constraint`.
* @param constraint the constraint to evaluate. When this is `undefined` or `null`,
* the method creates a new constraint.
* @returns a copy of `constraint`. When `minimum` has a value, the returned constraint
* always includes a minimum bound. When `constraint` has a maximum defined, both
* its minimum and maximum are checked against `minimum`.
*/
function atLeast(minimum: number, constraint?: Constraint<number>): Constraint<number> {
if (minimum === undefined || minimum === null) {
return constraint;
}
const atLeast = { ...(constraint ?? {}) };
atLeast.min = Math.max(atLeast.min ?? -Infinity, minimum);
if ("max" in atLeast) {
atLeast.max = Math.max(atLeast.max, minimum);
}
return atLeast;
}
/** Ensures a value falls within the minimum and maximum boundaries of a constraint.
* @param value the value to check. Nullish values are coerced to 0.
* @param constraint the constraint to evaluate against.
* @returns If the value is below the minimum constraint, the minimum bound is
* returned. If the value is above the maximum constraint, the maximum bound is
* returned. Otherwise, the value is returned.
*/
function fitToBounds(value: number, constraint: Constraint<number>) {
if (!constraint) {
return value;
}
const { min, max } = constraint;
const withUpperBound = Math.min(value ?? 0, max ?? Infinity);
const withLowerBound = Math.max(withUpperBound, min ?? -Infinity);
return withLowerBound;
}
/** Fits the length of a string within the minimum and maximum length boundaries
* of a constraint.
* @param value the value to check. Nullish values are coerced to the empty string.
* @param constraint the constraint to evaluate against.
* @param options.fillString a string to fill values from. Defaults to a space.
* When fillString contains multiple characters, each is filled in order. The
* fill string repeats when it gets to the end of the string and there are
* more characters to fill.
* @returns If the value is below the required length, returns a copy padded
* by the fillString. If the value is above the required length, returns a copy
* padded to the maximum length.
* */
function fitLength(
value: string,
constraint: Constraint<string>,
options?: { fillString?: string },
) {
if (!constraint) {
return value;
}
const { minLength, maxLength } = constraint;
const { fillString } = options ?? { fillString: " " };
const trimmed = (value ?? "").slice(0, maxLength ?? Infinity);
const result = trimmed.padEnd(minLength ?? trimmed.length, fillString);
return result;
}
/** Enforces a readonly field has a required value.
* @param value the value to check.
* @param constraint the constraint to evaluate against.
* @returns If the constraint's readonly field is `true`, returns the
* constraint's required value or `undefined` if none is specified.
* Otherwise returns the value.
* @remarks This method can be used to ensure a conditionally-calculated
* field becomes undefined. Simply specify `readonly` without a `requiredValue`
* then use `??` to perform the calculation.
*/
function enforceConstant(value: boolean, constraint: Constraint<boolean>) {
if (constraint?.readonly) {
return constraint.requiredValue;
} else {
return value;
}
}
/** Conditionally create a readonly true value.
* @param enabled When true, create the value.
* @returns When enabled is true, a readonly constraint with a constant value
* of `true`. Otherwise returns `undefined`.
*/
function readonlyTrueWhen(enabled: boolean) {
const readonlyValue = maybeReadonly(enabled, RequiresTrue);
const maybeReadonlyValue = maybe(enabled, readonlyValue);
return maybeReadonlyValue;
}
export {
atLeast,
atLeastSum,
maybe,
maybeReadonly,
fitToBounds,
enforceConstant,
readonlyTrueWhen,
fitLength,
AtLeastOne,
RequiresTrue,
};

View File

@@ -0,0 +1,262 @@
import { DefaultPasswordBoundaries, DefaultPasswordGenerationOptions, Policies } from "../data";
import { AtLeastOne } from "./constraints";
import { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
describe("DynamicPasswordPolicyConstraints", () => {
describe("constructor", () => {
it("uses default boundaries when the policy is disabled", () => {
const { constraints } = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
expect(constraints.policyInEffect).toBeFalsy();
expect(constraints.length).toEqual(DefaultPasswordBoundaries.length);
expect(constraints.lowercase).toBeUndefined();
expect(constraints.uppercase).toBeUndefined();
expect(constraints.number).toBeUndefined();
expect(constraints.special).toBeUndefined();
expect(constraints.minLowercase).toBeUndefined();
expect(constraints.minUppercase).toBeUndefined();
expect(constraints.minNumber).toEqual(DefaultPasswordBoundaries.minDigits);
expect(constraints.minSpecial).toEqual(DefaultPasswordBoundaries.minSpecialCharacters);
});
it("1 <= minLowercase when the policy requires lowercase", () => {
const policy = { ...Policies.Password.disabledValue, useLowercase: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.lowercase.readonly).toEqual(true);
expect(constraints.lowercase.requiredValue).toEqual(true);
expect(constraints.minLowercase).toEqual({ min: 1 });
});
it("1 <= minUppercase when the policy requires uppercase", () => {
const policy = { ...Policies.Password.disabledValue, useUppercase: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.uppercase.readonly).toEqual(true);
expect(constraints.uppercase.requiredValue).toEqual(true);
expect(constraints.minUppercase).toEqual({ min: 1 });
});
it("1 <= minNumber <= 9 when the policy requires a number", () => {
const policy = { ...Policies.Password.disabledValue, useNumbers: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.number.readonly).toEqual(true);
expect(constraints.number.requiredValue).toEqual(true);
expect(constraints.minNumber).toEqual({ min: 1, max: 9 });
});
it("1 <= minSpecial <= 9 when the policy requires a special character", () => {
const policy = { ...Policies.Password.disabledValue, useSpecial: true };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.special.readonly).toEqual(true);
expect(constraints.special.requiredValue).toEqual(true);
expect(constraints.minSpecial).toEqual({ min: 1, max: 9 });
});
it("numberCount <= minNumber <= 9 when the policy requires numberCount", () => {
const policy = { ...Policies.Password.disabledValue, useNumbers: true, numberCount: 2 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.number.readonly).toEqual(true);
expect(constraints.number.requiredValue).toEqual(true);
expect(constraints.minNumber).toEqual({ min: 2, max: 9 });
});
it("specialCount <= minSpecial <= 9 when the policy requires specialCount", () => {
const policy = { ...Policies.Password.disabledValue, useSpecial: true, specialCount: 2 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.special.readonly).toEqual(true);
expect(constraints.special.requiredValue).toEqual(true);
expect(constraints.minSpecial).toEqual({ min: 2, max: 9 });
});
it("uses the policy's minimum length when the policy defines one", () => {
const policy = { ...Policies.Password.disabledValue, minLength: 10 };
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.length).toEqual({ min: 10, max: 128 });
});
it("overrides the minimum length when it is less than the sum of minimums", () => {
const policy = {
...Policies.Password.disabledValue,
useUppercase: true,
useLowercase: true,
useNumbers: true,
numberCount: 5,
useSpecial: true,
specialCount: 5,
};
const { constraints } = new DynamicPasswordPolicyConstraints(policy);
// lower + upper + number + special = 1 + 1 + 5 + 5 = 12
expect(constraints.length).toEqual({ min: 12, max: 128 });
});
});
describe("calibrate", () => {
it("copies the boolean constraints into the calibration", () => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecial: true,
});
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.uppercase).toEqual(dynamic.constraints.uppercase);
expect(calibrated.constraints.lowercase).toEqual(dynamic.constraints.lowercase);
expect(calibrated.constraints.number).toEqual(dynamic.constraints.number);
expect(calibrated.constraints.special).toEqual(dynamic.constraints.special);
});
it.each([[true], [false], [undefined]])(
"outputs at least 1 constraint when the state's lowercase flag is true and useLowercase is %p",
(useLowercase) => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useLowercase,
});
const state = {
...DefaultPasswordGenerationOptions,
lowercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne);
},
);
it("outputs the `minLowercase` constraint when the state's lowercase flag is true and policy is disabled", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
lowercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toEqual(AtLeastOne);
});
it("disables the minLowercase constraint when the state's lowercase flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
lowercase: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minLowercase).toBeUndefined();
});
it.each([[true], [false], [undefined]])(
"outputs at least 1 constraint when the state's uppercase flag is true and useUppercase is %p",
(useUppercase) => {
const dynamic = new DynamicPasswordPolicyConstraints({
...Policies.Password.disabledValue,
useUppercase,
});
const state = {
...DefaultPasswordGenerationOptions,
uppercase: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minUppercase).toEqual(AtLeastOne);
},
);
it("disables the minUppercase constraint when the state's uppercase flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
uppercase: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minUppercase).toBeUndefined();
});
it("outputs the minNumber constraint when the state's number flag is true", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
number: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toEqual(dynamic.constraints.minNumber);
});
it("disables the minNumber constraint when the state's number flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
number: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minNumber).toBeUndefined();
});
it("outputs the minSpecial constraint when the state's special flag is true", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
special: true,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toEqual(dynamic.constraints.minSpecial);
});
it("disables the minSpecial constraint when the state's special flag is false", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const state = {
...DefaultPasswordGenerationOptions,
special: false,
};
const calibrated = dynamic.calibrate(state);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("copies the minimum length constraint", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
it("overrides the minimum length constraint when it is less than the sum of the state's minimums", () => {
const dynamic = new DynamicPasswordPolicyConstraints(Policies.Password.disabledValue);
const calibrated = dynamic.calibrate(DefaultPasswordGenerationOptions);
expect(calibrated.constraints.minSpecial).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,100 @@
import {
DynamicStateConstraints,
PolicyConstraints,
StateConstraints,
} from "@bitwarden/common/tools/types";
import { DefaultPasswordBoundaries } from "../data";
import { PasswordGeneratorPolicy, PasswordGeneratorSettings } from "../types";
import { atLeast, atLeastSum, maybe, readonlyTrueWhen, AtLeastOne } from "./constraints";
import { PasswordPolicyConstraints } from "./password-policy-constraints";
/** Creates state constraints by blending policy and password settings. */
export class DynamicPasswordPolicyConstraints
implements DynamicStateConstraints<PasswordGeneratorSettings>
{
/** Instantiates the object.
* @param policy the password policy to enforce. This cannot be
* `null` or `undefined`.
*/
constructor(policy: PasswordGeneratorPolicy) {
const minLowercase = maybe(policy.useLowercase, AtLeastOne);
const minUppercase = maybe(policy.useUppercase, AtLeastOne);
const minNumber = atLeast(
policy.numberCount || (policy.useNumbers && AtLeastOne.min),
DefaultPasswordBoundaries.minDigits,
);
const minSpecial = atLeast(
policy.specialCount || (policy.useSpecial && AtLeastOne.min),
DefaultPasswordBoundaries.minSpecialCharacters,
);
const baseLength = atLeast(policy.minLength, DefaultPasswordBoundaries.length);
const subLengths = [minLowercase, minUppercase, minNumber, minSpecial];
const length = atLeastSum(baseLength, subLengths);
this.constraints = Object.freeze({
policyInEffect: policyInEffect(policy),
lowercase: readonlyTrueWhen(policy.useLowercase),
uppercase: readonlyTrueWhen(policy.useUppercase),
number: readonlyTrueWhen(policy.useNumbers),
special: readonlyTrueWhen(policy.useSpecial),
length,
minLowercase,
minUppercase,
minNumber,
minSpecial,
});
}
/** Constraints derived from the policy and application-defined defaults;
* @remarks these limits are absolute and should be transmitted to the UI
*/
readonly constraints: PolicyConstraints<PasswordGeneratorSettings>;
calibrate(state: PasswordGeneratorSettings): StateConstraints<PasswordGeneratorSettings> {
// decide which constraints are active
const lowercase = state.lowercase || this.constraints.lowercase?.requiredValue || false;
const uppercase = state.uppercase || this.constraints.uppercase?.requiredValue || false;
const number = state.number || this.constraints.number?.requiredValue || false;
const special = state.special || this.constraints.special?.requiredValue || false;
// minimum constraints cannot `atLeast(state...) because doing so would force
// the constrained value to only increase
const constraints: PolicyConstraints<PasswordGeneratorSettings> = {
...this.constraints,
minLowercase: maybe<number>(lowercase, this.constraints.minLowercase ?? AtLeastOne),
minUppercase: maybe<number>(uppercase, this.constraints.minUppercase ?? AtLeastOne),
minNumber: maybe<number>(number, this.constraints.minNumber),
minSpecial: maybe<number>(special, this.constraints.minSpecial),
};
// lower bound of length must always at least fit its sub-lengths
constraints.length = atLeastSum(this.constraints.length, [
atLeast(state.minNumber, constraints.minNumber),
atLeast(state.minSpecial, constraints.minSpecial),
atLeast(state.minLowercase, constraints.minLowercase),
atLeast(state.minUppercase, constraints.minUppercase),
]);
const stateConstraints = new PasswordPolicyConstraints(constraints);
return stateConstraints;
}
}
function policyInEffect(policy: PasswordGeneratorPolicy): boolean {
const policies = [
policy.useUppercase,
policy.useLowercase,
policy.useNumbers,
policy.useSpecial,
policy.minLength > DefaultPasswordBoundaries.length.min,
policy.numberCount > DefaultPasswordBoundaries.minDigits.min,
policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min,
];
return policies.includes(true);
}

View File

@@ -1,5 +1,7 @@
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
export { DynamicPasswordPolicyConstraints } from "./dynamic-password-policy-constraints";
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
export { PassphrasePolicyConstraints } from "./passphrase-policy-constraints";
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
export { passwordLeastPrivilege } from "./password-least-privilege";

View File

@@ -0,0 +1,134 @@
import { DefaultPassphraseBoundaries, Policies } from "../data";
import { PassphrasePolicyConstraints } from "./passphrase-policy-constraints";
const SomeSettings = {
capitalize: false,
includeNumber: false,
numWords: 3,
wordSeparator: "-",
};
describe("PassphrasePolicyConstraints", () => {
describe("constructor", () => {
it("uses default boundaries when the policy is disabled", () => {
const { constraints } = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
expect(constraints.policyInEffect).toBeFalsy();
expect(constraints.capitalize).toBeUndefined();
expect(constraints.includeNumber).toBeUndefined();
expect(constraints.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
});
it("requires capitalization when the policy requires capitalization", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
capitalize: true,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.capitalize).toMatchObject({ readonly: true, requiredValue: true });
});
it("requires a number when the policy requires a number", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
includeNumber: true,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.includeNumber).toMatchObject({ readonly: true, requiredValue: true });
});
it("minNumberWords <= numWords.min when the policy requires numberCount", () => {
const { constraints } = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
minNumberWords: 10,
});
expect(constraints.policyInEffect).toBeTruthy();
expect(constraints.numWords).toMatchObject({
min: 10,
max: DefaultPassphraseBoundaries.numWords.max,
});
});
});
describe("adjust", () => {
it("allows an empty word separator", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "" });
expect(wordSeparator).toEqual("");
});
it("takes only the first character of wordSeparator", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { wordSeparator } = policy.adjust({ ...SomeSettings, wordSeparator: "?." });
expect(wordSeparator).toEqual("?");
});
it.each([
[1, 3],
[21, 20],
])("fits numWords (=%p) within the default bounds (3 <= %p <= 20)", (value, expected) => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const { numWords } = policy.adjust({ ...SomeSettings, numWords: value });
expect(numWords).toEqual(expected);
});
it.each([
[1, 4, 4],
[21, 20, 20],
])(
"fits numWords (=%p) within the policy bounds (%p <= %p <= 20)",
(value, minNumberWords, expected) => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
minNumberWords,
});
const { numWords } = policy.adjust({ ...SomeSettings, numWords: value });
expect(numWords).toEqual(expected);
},
);
it("sets capitalize to true when the policy requires it", () => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
capitalize: true,
});
const { capitalize } = policy.adjust({ ...SomeSettings, capitalize: false });
expect(capitalize).toBeTruthy();
});
it("sets includeNumber to true when the policy requires it", () => {
const policy = new PassphrasePolicyConstraints({
...Policies.Passphrase.disabledValue,
includeNumber: true,
});
const { includeNumber } = policy.adjust({ ...SomeSettings, capitalize: false });
expect(includeNumber).toBeTruthy();
});
});
describe("fix", () => {
it("returns its input", () => {
const policy = new PassphrasePolicyConstraints(Policies.Passphrase.disabledValue);
const result = policy.fix(SomeSettings);
expect(result).toBe(SomeSettings);
});
});
});

View File

@@ -0,0 +1,51 @@
import { PolicyConstraints, StateConstraints } from "@bitwarden/common/tools/types";
import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions } from "../data";
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
import { atLeast, enforceConstant, fitLength, fitToBounds, readonlyTrueWhen } from "./constraints";
export class PassphrasePolicyConstraints implements StateConstraints<PassphraseGenerationOptions> {
/** Creates a passphrase policy constraints
* @param policy the password policy to enforce. This cannot be
* `null` or `undefined`.
*/
constructor(readonly policy: PassphraseGeneratorPolicy) {
this.constraints = {
policyInEffect: policyInEffect(policy),
wordSeparator: { minLength: 0, maxLength: 1 },
capitalize: readonlyTrueWhen(policy.capitalize),
includeNumber: readonlyTrueWhen(policy.includeNumber),
numWords: atLeast(policy.minNumberWords, DefaultPassphraseBoundaries.numWords),
};
}
constraints: Readonly<PolicyConstraints<PassphraseGenerationOptions>>;
adjust(state: PassphraseGenerationOptions): PassphraseGenerationOptions {
const result: PassphraseGenerationOptions = {
wordSeparator: fitLength(state.wordSeparator, this.constraints.wordSeparator, {
fillString: DefaultPassphraseGenerationOptions.wordSeparator,
}),
capitalize: enforceConstant(state.capitalize, this.constraints.capitalize),
includeNumber: enforceConstant(state.includeNumber, this.constraints.includeNumber),
numWords: fitToBounds(state.numWords, this.constraints.numWords),
};
return result;
}
fix(state: PassphraseGenerationOptions): PassphraseGenerationOptions {
return state;
}
}
function policyInEffect(policy: PassphraseGeneratorPolicy): boolean {
const policies = [
policy.capitalize,
policy.includeNumber,
policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min,
];
return policies.includes(true);
}

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