Merge branch 'main' into vault/pm-9666/implement-edit-item-view-individual-vault

This commit is contained in:
Alec Rippberger 2024-09-11 09:53:06 -05:00 committed by GitHub
commit 788348496c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1515 additions and 190 deletions

11
.github/CODEOWNERS vendored
View File

@ -100,12 +100,19 @@ apps/desktop/src/services/native-message-handler.service.ts @bitwarden/team-auto
## Component Library ##
.storybook @bitwarden/team-design-system
libs/components @bitwarden/team-design-system
apps/browser/src/platform/popup/layout @bitwarden/team-design-system
apps/web/src/app/layouts @bitwarden/team-design-system
apps/browser/src/platform/popup/layout @bitwarden/team-design-system
apps/web/src/app/layouts @bitwarden/team-design-system
## Desktop native module ##
apps/desktop/desktop_native @bitwarden/team-platform-dev
## Key management team files ##
apps/desktop/src/key-management @bitwarden/team-key-management-dev
apps/web/src/key-management @bitwarden/team-key-management-dev
apps/browser/src/key-management @bitwarden/team-key-management-dev
apps/cli/src/key-management @bitwarden/team-key-management-dev
libs/common/src/key-management @bitwarden/team-key-management-dev
## DevOps team files ##
/.github/workflows @bitwarden/dept-devops

View File

@ -1,6 +1,6 @@
{
"name": "@bitwarden/browser",
"version": "2024.9.0",
"version": "2024.9.1",
"scripts": {
"build": "cross-env MANIFEST_VERSION=3 webpack",
"build:mv2": "webpack",

View File

@ -2,7 +2,7 @@
"manifest_version": 2,
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.9.0",
"version": "2024.9.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -3,7 +3,7 @@
"minimum_chrome_version": "102.0",
"name": "__MSG_extName__",
"short_name": "__MSG_appName__",
"version": "2024.9.0",
"version": "2024.9.1",
"description": "__MSG_extDesc__",
"default_locale": "en",
"author": "Bitwarden Inc.",

View File

@ -10,6 +10,7 @@ import {
} from "@bitwarden/angular/auth/guards";
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap";
import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect";
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
import {
AnonLayoutWrapperComponent,
@ -98,7 +99,6 @@ import { TrashComponent } from "../vault/popup/settings/trash.component";
import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component";
import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component";
import { extensionRefreshRedirect } from "./extension-refresh-route-utils";
import { debounceNavigationGuard } from "./services/debounce-navigation.service";
import { TabsV2Component } from "./tabs-v2.component";
import { TabsComponent } from "./tabs.component";

View File

@ -1 +1 @@
<bit-password-settings />
<bit-password-generator />

View File

@ -1,14 +1,11 @@
import { Component } from "@angular/core";
import {
PassphraseSettingsComponent,
PasswordSettingsComponent,
} from "@bitwarden/generator-components";
import { PasswordGeneratorComponent } from "@bitwarden/generator-components";
@Component({
standalone: true,
selector: "credential-generator",
templateUrl: "credential-generator.component.html",
imports: [PassphraseSettingsComponent, PasswordSettingsComponent],
imports: [PasswordGeneratorComponent],
})
export class CredentialGeneratorComponent {}

View File

@ -64,7 +64,7 @@
"chalk": "4.1.2",
"commander": "11.1.0",
"form-data": "4.0.0",
"https-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.5",
"inquirer": "8.2.6",
"jsdom": "24.1.3",
"jszip": "3.10.1",

View File

@ -25,7 +25,7 @@
"**/node_modules/argon2/package.json",
"**/node_modules/argon2/build/Release/argon2.node"
],
"electronVersion": "32.0.1",
"electronVersion": "32.0.2",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",

View File

@ -62,7 +62,7 @@
class="tw-px-2 tw-py-4"
[ngClass]="{
'tw-py-1': !(selectableProduct === selectedPlan),
'tw-py-0': selectableProduct === selectedPlan,
'tw-py-0': selectableProduct === selectedPlan
}"
>
<h3 class="tw-text-lg tw-font-bold">
@ -308,9 +308,14 @@
<a></a>
</p>
<app-payment
*ngIf="upgradeRequiresPaymentMethod || showPayment"
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI"
[hideCredit]="true"
></app-payment>
<app-payment-v2
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI"
[showAccountCredit]="false"
>
</app-payment-v2>
<app-tax-info
*ngIf="showPayment || upgradeRequiresPaymentMethod"
(onCountryChanged)="changedCountry()"
@ -389,17 +394,21 @@
bitTypography="body2"
*ngIf="
selectedPlan.PasswordManager.hasAdditionalStorageOption &&
!organization.useSecretsManager
!organization.useSecretsManager &&
organization.maxStorageGb > 0
"
>
<span>
{{ 0 }}
{{ organization.maxStorageGb }}
{{ "additionalStorageGbMessage" | i18n }}
&times;
{{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }}
/{{ "year" | i18n }}
</span>
<span>{{ 0 | currency: "$" }}</span>
<span>{{
organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb
| currency: "$"
}}</span>
</p>
<!-- secrets manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
@ -505,17 +514,21 @@
bitTypography="body2"
*ngIf="
selectedPlan.PasswordManager.hasAdditionalStorageOption &&
!organization.useSecretsManager
!organization.useSecretsManager &&
organization.maxStorageGb > 0
"
>
<span>
{{ 0 }}
{{ organization.maxStorageGb }}
{{ "additionalStorageGbMessage" | i18n }}
&times;
{{ selectedPlan.PasswordManager.additionalStoragePricePerGb | currency: "$" }}
{{ additionalStoragePriceMonthly(selectedPlan) | currency: "$" }}
/{{ "month" | i18n }}
</span>
<span>{{ 0 | currency: "$" }}</span>
<span>{{
organization.maxStorageGb * selectedPlan.PasswordManager.additionalStoragePricePerGb
| currency: "$"
}}</span>
</p>
<!-- secrets manager summary for monthly -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">

View File

@ -21,22 +21,27 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/request/organization-keys.request";
import { OrganizationUpgradeRequest } from "@bitwarden/common/admin-console/models/request/organization-upgrade.request";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import {
PaymentMethodType,
PlanInterval,
PlanType,
ProductTierType,
PlanInterval,
} from "@bitwarden/common/billing/enums";
import { PaymentRequest } from "@bitwarden/common/billing/models/request/payment.request";
import { UpdatePaymentMethodRequest } from "@bitwarden/common/billing/models/request/update-payment-method.request";
import { BillingResponse } from "@bitwarden/common/billing/models/response/billing.response";
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
import { DialogService, ToastService } from "@bitwarden/components";
import { PaymentV2Component } from "../shared/payment/payment-v2.component";
import { PaymentComponent } from "../shared/payment/payment.component";
import { TaxInfoComponent } from "../shared/tax-info.component";
@ -80,6 +85,7 @@ interface OnSuccessArgs {
})
export class ChangePlanDialogComponent implements OnInit, OnDestroy {
@ViewChild(PaymentComponent) paymentComponent: PaymentComponent;
@ViewChild(PaymentV2Component) paymentV2Component: PaymentV2Component;
@ViewChild(TaxInfoComponent) taxComponent: TaxInfoComponent;
@Input() acceptingSponsorship = false;
@ -155,6 +161,8 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
totalOpened: boolean = false;
currentPlan: PlanResponse;
deprecateStripeSourcesAPI: boolean;
private destroy$ = new Subject<void>();
constructor(
@ -171,9 +179,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
private messagingService: MessagingService,
private formBuilder: FormBuilder,
private organizationApiService: OrganizationApiServiceAbstraction,
private configService: ConfigService,
private billingApiService: BillingApiServiceAbstraction,
) {}
async ngOnInit(): Promise<void> {
this.deprecateStripeSourcesAPI = await this.configService.getFeatureFlag(
FeatureFlag.AC2476_DeprecateStripeSourcesAPI,
);
if (this.dialogParams.organizationId) {
this.currentPlanName = this.resolvePlanName(this.dialogParams.productTierType);
this.sub =
@ -477,6 +491,13 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
);
}
additionalStoragePriceMonthly(selectedPlan: PlanResponse) {
if (!selectedPlan.isAnnual) {
return selectedPlan.PasswordManager.additionalStoragePricePerGb;
}
return selectedPlan.PasswordManager.additionalStoragePricePerGb / 12;
}
additionalServiceAccountTotal(plan: PlanResponse): number {
if (!plan.SecretsManager.hasAdditionalServiceAccountOption || this.additionalServiceAccount) {
return 0;
@ -525,9 +546,18 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
get total() {
if (this.organization.useSecretsManager) {
return this.passwordManagerSubtotal + this.secretsManagerSubtotal + this.taxCharges || 0;
return (
this.passwordManagerSubtotal +
this.additionalStorageTotal(this.selectedPlan) +
this.secretsManagerSubtotal +
this.taxCharges || 0
);
}
return this.passwordManagerSubtotal + this.taxCharges || 0;
return (
this.passwordManagerSubtotal +
this.additionalStorageTotal(this.selectedPlan) +
this.taxCharges || 0
);
}
get teamsStarterPlanIsAvailable() {
@ -579,7 +609,16 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
}
changedCountry() {
if (this.paymentComponent && this.taxComponent) {
if (this.deprecateStripeSourcesAPI && this.paymentV2Component && this.taxComponent) {
this.paymentV2Component.showBankAccount = this.taxComponent.country === "US";
if (
!this.paymentV2Component.showBankAccount &&
this.paymentV2Component.selected === PaymentMethodType.BankAccount
) {
this.paymentV2Component.select(PaymentMethodType.Card);
}
} else if (this.paymentComponent && this.taxComponent) {
this.paymentComponent!.hideBank = this.taxComponent?.taxFormGroup?.value.country !== "US";
// Bank Account payments are only available for US customers
if (
@ -600,7 +639,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
const doSubmit = async (): Promise<string> => {
let orgId: string = null;
orgId = await this.updateOrganization(orgId);
orgId = await this.updateOrganization();
this.toastService.showToast({
variant: "success",
title: null,
@ -634,11 +673,15 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.dialogRef.close();
};
private async updateOrganization(orgId: string) {
private async updateOrganization() {
const request = new OrganizationUpgradeRequest();
if (this.selectedPlan.productTier !== ProductTierType.Families) {
request.additionalSeats = this.organization.seats;
}
if (this.organization.maxStorageGb > this.selectedPlan.PasswordManager.baseStorageGb) {
request.additionalStorageGb =
this.organization.maxStorageGb - this.selectedPlan.PasswordManager.baseStorageGb;
}
request.premiumAccessAddon =
this.selectedPlan.PasswordManager.hasPremiumAccessOption &&
this.formGroup.controls.premiumAccessAddon.value;
@ -652,13 +695,33 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
this.buildSecretsManagerRequest(request);
if (this.upgradeRequiresPaymentMethod || this.showPayment) {
const tokenResult = await this.paymentComponent.createPaymentToken();
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = tokenResult[0];
paymentRequest.paymentMethodType = tokenResult[1];
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
if (this.deprecateStripeSourcesAPI) {
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
updatePaymentMethodRequest.paymentSource = tokenizedPaymentSource;
updatePaymentMethodRequest.taxInformation = {
country: this.taxComponent.country,
postalCode: this.taxComponent.postalCode,
taxId: this.taxComponent.taxId,
line1: this.taxComponent.line1,
line2: this.taxComponent.line2,
city: this.taxComponent.city,
state: this.taxComponent.state,
};
await this.billingApiService.updateOrganizationPaymentMethod(
this.organizationId,
updatePaymentMethodRequest,
);
} else {
const tokenResult = await this.paymentComponent.createPaymentToken();
const paymentRequest = new PaymentRequest();
paymentRequest.paymentToken = tokenResult[0];
paymentRequest.paymentMethodType = tokenResult[1];
paymentRequest.country = this.taxComponent.taxFormGroup?.value.country;
paymentRequest.postalCode = this.taxComponent.taxFormGroup?.value.postalCode;
await this.organizationApiService.updatePayment(this.organizationId, paymentRequest);
}
}
// Backfill pub/priv key if necessary

View File

@ -86,7 +86,7 @@ export class PaymentV2Component implements OnInit, OnDestroy {
/** Programmatically select the provided payment method. */
select = (paymentMethod: PaymentMethodType) => {
this.formGroup.value.paymentMethod = paymentMethod;
this.formGroup.get("paymentMethod").patchValue(paymentMethod);
};
protected submit = async () => {

View File

@ -157,7 +157,7 @@
</button>
<button
bitMenuItem
*ngIf="canEditCipher || !vaultBulkManagementActionEnabled"
*ngIf="canManageCollection || !vaultBulkManagementActionEnabled"
(click)="deleteCipher()"
type="button"
>

View File

@ -36,6 +36,7 @@ export class VaultCipherRowComponent implements OnInit {
@Input() viewingOrgVault: boolean;
@Input() canEditCipher: boolean;
@Input() vaultBulkManagementActionEnabled: boolean;
@Input() canManageCollection: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent>();

View File

@ -133,6 +133,9 @@
[collections]="allCollections"
[checked]="selection.isSelected(item)"
[canEditCipher]="canEditCipher(item.cipher) && vaultBulkManagementActionEnabled"
[canManageCollection]="
canManageCollection(item.cipher) && vaultBulkManagementActionEnabled
"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"

View File

@ -48,6 +48,7 @@ export class VaultItemsComponent {
@Input() addAccessToggle: boolean;
@Input() restrictProviderAccess: boolean;
@Input() vaultBulkManagementActionEnabled = false;
@Input() activeCollection: CollectionView | undefined;
private _ciphers?: CipherView[] = [];
@Input() get ciphers(): CipherView[] {
@ -218,6 +219,33 @@ export class VaultItemsComponent {
);
}
protected canManageCollection(cipher: CipherView) {
if (cipher.organizationId == null) {
return true;
}
// Check for admin access in AC vault
if (this.showAdminActions) {
const organization = this.allOrganizations.find((o) => o.id === cipher.organizationId);
if (organization?.permissions.editAnyCollection) {
return true;
}
if (organization?.allowAdminAccessToAllCollectionItems && organization.isAdmin) {
return true;
}
}
if (this.activeCollection) {
return this.activeCollection.manage;
}
return this.allCollections
.filter((c) => cipher.collectionIds.includes(c.id))
.some((collection) => collection.manage);
}
private refreshItems() {
const collections: VaultItem[] = this.collections.map((collection) => ({ collection }));
const ciphers: VaultItem[] = this.ciphers.map((cipher) => ({ cipher }));
@ -294,20 +322,16 @@ export class VaultItemsComponent {
const hasPersonalItems = this.hasPersonalItems();
const uniqueCipherOrgIds = this.getUniqueOrganizationIds();
const organizations = Array.from(uniqueCipherOrgIds, (orgId) =>
this.allOrganizations.find((o) => o.id === orgId),
);
const canEditOrManageAllCiphers =
organizations.length > 0 &&
organizations.every((org) => org?.canEditAllCiphers(this.restrictProviderAccess));
const canManageCollectionCiphers = this.selection.selected
.filter((item) => item.cipher)
.every(({ cipher }) => this.canManageCollection(cipher));
const canDeleteCollections = this.selection.selected
.filter((item) => item.collection)
.every((item) => item.collection && this.canDeleteCollection(item.collection));
const userCanDeleteAccess =
(canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && canDeleteCollections;
const userCanDeleteAccess = canManageCollectionCiphers && canDeleteCollections;
if (
userCanDeleteAccess ||

View File

@ -57,6 +57,7 @@
[showBulkAddToCollections]="vaultBulkManagementActionEnabled$ | async"
(onEvent)="onVaultItemsEvent($event)"
[vaultBulkManagementActionEnabled]="vaultBulkManagementActionEnabled$ | async"
[activeCollection]="selectedCollection?.node"
>
</app-vault-items>
<div

View File

@ -0,0 +1,29 @@
<!doctype html>
<html class="theme_light">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1010" />
<meta name="theme-color" content="#175DDC" />
<title>Bitwarden Web vault</title>
<link rel="apple-touch-icon" sizes="180x180" href="../images/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="../images/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="../images/icons/favicon-16x16.png" />
<link rel="mask-icon" href="../images/icons/safari-pinned-tab.svg" color="#175DDC" />
<link rel="manifest" href="../manifest.json" />
</head>
<body class="layout_frontend">
<div class="tw-p-8 tw-flex">
<img class="new-logo-themed" alt="Bitwarden" />
<div class="spinner-container tw-justify-center">
<i
class="bwi bwi-spinner bwi-spin bwi-3x tw-text-muted"
title="Loading"
aria-hidden="true"
></i>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,17 @@
// This redirect connector is used to redirect users to the correct URL after they have been sent here from an email link.
// The fragment contains the information needed to redirect the user to the correct page.
// This is required because android app links couldn't properly handle the angular hash based route we originally had in the email link.
window.addEventListener("load", () => {
// ex: https://vault.bitwarden.com/redirect-connector.html#finish-signup?token=fakeToken&email=example%40example.com&fromEmail=true
const currentUrl = new URL(window.location.href);
// Get the fragment (everything after the #)
const fragment = currentUrl.hash.substring(1); // Remove the leading #
if (!fragment) {
throw new Error("No fragment found in URL. Cannot determine redirect.");
}
const newUrl = `${window.location.origin}/#/${fragment}`;
window.location.href = newUrl;
});

View File

@ -111,6 +111,11 @@ const plugins = [
filename: "sso-connector.html",
chunks: ["connectors/sso"],
}),
new HtmlWebpackPlugin({
template: "./src/connectors/redirect.html",
filename: "redirect-connector.html",
chunks: ["connectors/redirect", "styles"],
}),
new HtmlWebpackPlugin({
template: "./src/connectors/captcha.html",
filename: "captcha-connector.html",
@ -325,6 +330,7 @@ const webpackConfig = {
"connectors/sso": "./src/connectors/sso.ts",
"connectors/captcha": "./src/connectors/captcha.ts",
"connectors/duo-redirect": "./src/connectors/duo-redirect.ts",
"connectors/redirect": "./src/connectors/redirect.ts",
styles: ["./src/scss/styles.scss", "./src/scss/tailwind.css"],
theme_head: "./src/theme.ts",
},

View File

@ -1,5 +1,5 @@
import { inject } from "@angular/core";
import { Router, UrlTree } from "@angular/router";
import { UrlTree, Router } from "@angular/router";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";

View File

@ -77,7 +77,7 @@ export type SingleUserDependency = {
export type OnDependency = {
/** The stream that controls emissions
*/
on$: Observable<void>;
on$: Observable<any>;
};
/** A pattern for types that emit when a dependency is `true`.

View File

@ -1,11 +1,6 @@
import { GenerationRequest } from "../../types";
/** Options that provide contextual information about the application state
* when an integration is invoked.
*/
export type IntegrationRequest = {
/** @param website The domain of the website the requested integration is used
* within. This should be set to `null` when the request is not specific
* to any website.
* @remarks this field contains sensitive data
*/
website: string | null;
};
export type IntegrationRequest = Partial<GenerationRequest>;

View File

@ -46,3 +46,20 @@ export type Constraints<T> = {
/** utility type for methods that evaluate constraints generically. */
export type AnyConstraint = PrimitiveConstraint & StringConstraints & NumberConstraints;
/** Options that provide contextual information about the application state
* when a generator is invoked.
*/
export type VaultItemRequest = {
/** The domain of the website the requested credential is used
* within. This should be set to `null` when the request is not specific
* to any website.
* @remarks this field contains sensitive data
*/
website: string | null;
};
/** Options that provide contextual information about the application state
* when a generator is invoked.
*/
export type GenerationRequest = Partial<VaultItemRequest>;

View File

@ -4,41 +4,60 @@ import { ReactiveFormsModule } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import {
CardComponent,
CheckboxModule,
ColorPasswordModule,
FormFieldModule,
IconButtonModule,
InputModule,
ItemModule,
SectionComponent,
SectionHeaderComponent,
ToggleGroupModule,
} from "@bitwarden/components";
import { CredentialGeneratorService } from "@bitwarden/generator-core";
import {
createRandomizer,
CredentialGeneratorService,
Randomizer,
} from "@bitwarden/generator-core";
const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
/** Shared module containing generator component dependencies */
@NgModule({
imports: [SectionComponent, SectionHeaderComponent, CardComponent],
imports: [CardComponent, SectionComponent, SectionHeaderComponent],
exports: [
CardComponent,
CheckboxModule,
CommonModule,
ColorPasswordModule,
FormFieldModule,
IconButtonModule,
InputModule,
ItemModule,
JslibModule,
JslibServicesModule,
FormFieldModule,
CommonModule,
ReactiveFormsModule,
ColorPasswordModule,
InputModule,
CheckboxModule,
SectionComponent,
SectionHeaderComponent,
CardComponent,
ToggleGroupModule,
],
providers: [
safeProvider({
provide: RANDOMIZER,
useFactory: createRandomizer,
deps: [CryptoService],
}),
safeProvider({
provide: CredentialGeneratorService,
useClass: CredentialGeneratorService,
deps: [StateProvider, PolicyService],
deps: [RANDOMIZER, StateProvider, PolicyService],
}),
],
declarations: [],

View File

@ -2,3 +2,4 @@ export { PassphraseSettingsComponent } from "./passphrase-settings.component";
export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component";
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
export { PasswordSettingsComponent } from "./password-settings.component";
export { PasswordGeneratorComponent } from "./password-generator.component";

View File

@ -0,0 +1,44 @@
<bit-toggle-group
fullWidth
class="tw-mb-4"
[selected]="credentialType$ | async"
(selectedChange)="onCredentialTypeChanged($event)"
attr.aria-label="{{ 'type' | i18n }}"
>
<bit-toggle value="password">
{{ "password" | i18n }}
</bit-toggle>
<bit-toggle value="passphrase">
{{ "passphrase" | i18n }}
</bit-toggle>
</bit-toggle-group>
<bit-card class="tw-flex tw-justify-between tw-mb-4">
<div class="tw-grow">
<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">
<button type="button" bitIconButton="bwi-generate" buttonType="main" (click)="generate$.next()">
{{ "generatePassword" | i18n }}
</button>
<button
type="button"
bitIconButton="bwi-clone"
buttonType="main"
[appCopyClick]="value$ | async"
>
{{ "copyPassword" | i18n }}
</button>
</div>
</bit-card>
<bit-password-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'password'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>
<bit-passphrase-settings
class="tw-mt-6"
*ngIf="(credentialType$ | async) === 'passphrase'"
[userId]="this.userId$ | async"
(onUpdated)="generate$.next()"
/>

View File

@ -0,0 +1,117 @@
import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, distinctUntilChanged, map, Subject, switchMap, takeUntil } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CredentialGeneratorService, Generators, GeneratorType } from "@bitwarden/generator-core";
import { GeneratedCredential } from "@bitwarden/generator-history";
import { DependenciesModule } from "./dependencies";
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
import { PasswordSettingsComponent } from "./password-settings.component";
/** Options group for passwords */
@Component({
standalone: true,
selector: "bit-password-generator",
templateUrl: "password-generator.component.html",
imports: [DependenciesModule, PasswordSettingsComponent, PassphraseSettingsComponent],
})
export class PasswordGeneratorComponent implements OnInit, OnDestroy {
constructor(
private generatorService: CredentialGeneratorService,
private accountService: AccountService,
private zone: NgZone,
) {}
/** Binds the passphrase component to a specific user's settings.
* When this input is not provided, the form binds to the active
* user
*/
@Input()
userId: UserId | null;
/** tracks the currently selected credential type */
protected credentialType$ = new BehaviorSubject<GeneratorType>("password");
/** Emits the last generated value. */
protected readonly value$ = new BehaviorSubject<string>("");
/** Emits when the userId changes */
protected readonly userId$ = new BehaviorSubject<UserId>(null);
/** Emits when a new credential is requested */
protected readonly generate$ = new Subject<void>();
/** Tracks changes to the selected credential type
* @param type the new credential type
*/
protected onCredentialTypeChanged(type: GeneratorType) {
if (this.credentialType$.value !== type) {
this.credentialType$.next(type);
this.generate$.next();
}
}
/** Emits credentials created from a generation request. */
@Output()
readonly onGenerated = new EventEmitter<GeneratedCredential>();
async ngOnInit() {
if (this.userId) {
this.userId$.next(this.userId);
} else {
this.accountService.activeAccount$
.pipe(
map((acct) => acct.id),
distinctUntilChanged(),
takeUntil(this.destroyed),
)
.subscribe(this.userId$);
}
this.credentialType$
.pipe(
switchMap((type) => this.typeToGenerator$(type)),
takeUntil(this.destroyed),
)
.subscribe((generated) => {
// update subjects within the angular zone so that the
// template bindings refresh immediately
this.zone.run(() => {
this.onGenerated.next(generated);
this.value$.next(generated.credential);
});
});
}
private typeToGenerator$(type: GeneratorType) {
const dependencies = {
on$: this.generate$,
userId$: this.userId$,
};
switch (type) {
case "password":
return this.generatorService.generate$(Generators.Password, dependencies);
case "passphrase":
return this.generatorService.generate$(Generators.Passphrase, dependencies);
default:
throw new Error(`Invalid generator type: "${type}"`);
}
}
private readonly destroyed = new Subject<void>();
ngOnDestroy(): void {
// tear down subscriptions
this.destroyed.complete();
// finalize subjects
this.generate$.complete();
this.value$.complete();
// finalize component bindings
this.onGenerated.complete();
}
}

View File

@ -1,5 +1,8 @@
import { Randomizer } from "../abstractions";
import { PasswordRandomizer } from "../engine";
import { PASSPHRASE_SETTINGS, PASSWORD_SETTINGS } from "../strategies/storage";
import {
CredentialGenerator,
PassphraseGenerationOptions,
PassphraseGeneratorPolicy,
PasswordGenerationOptions,
@ -14,6 +17,12 @@ import { DefaultPasswordGenerationOptions } from "./default-password-generation-
import { Policies } from "./policies";
const PASSPHRASE = Object.freeze({
category: "passphrase",
engine: {
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> {
return new PasswordRandomizer(randomizer);
},
},
settings: {
initial: DefaultPassphraseGenerationOptions,
constraints: {
@ -32,6 +41,12 @@ const PASSPHRASE = Object.freeze({
>);
const PASSWORD = Object.freeze({
category: "password",
engine: {
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> {
return new PasswordRandomizer(randomizer);
},
},
settings: {
initial: DefaultPasswordGenerationOptions,
constraints: {

View File

@ -335,4 +335,40 @@ describe("PasswordRandomizer", () => {
expect(result).toEqual("foo-foo");
});
});
describe("generate", () => {
it("processes password generation options", async () => {
const password = new PasswordRandomizer(randomizer);
const result = await password.generate(
{},
{
length: 10,
},
);
expect(result.category).toEqual("password");
});
it("processes passphrase generation options", async () => {
const password = new PasswordRandomizer(randomizer);
const result = await password.generate(
{},
{
numWords: 10,
},
);
expect(result.category).toEqual("passphrase");
});
it("throws when it cannot recognize the options type", async () => {
const password = new PasswordRandomizer(randomizer);
const result = password.generate({}, {});
await expect(result).rejects.toBeInstanceOf(Error);
});
});
});

View File

@ -1,13 +1,26 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { GenerationRequest } from "@bitwarden/common/tools/types";
import {
CredentialGenerator,
GeneratedCredential,
PassphraseGenerationOptions,
PasswordGenerationOptions,
} from "../types";
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest } from "../util";
import { Randomizer } from "./abstractions";
import { Ascii } from "./data";
import { CharacterSet, EffWordListRequest, RandomAsciiRequest } from "./types";
/** Generation algorithms that produce randomized secrets */
export class PasswordRandomizer {
export class PasswordRandomizer
implements
CredentialGenerator<PassphraseGenerationOptions>,
CredentialGenerator<PasswordGenerationOptions>
{
/** Instantiates the password randomizer
* @param random data source for random data
* @param randomizer data source for random data
*/
constructor(private randomizer: Randomizer) {}
@ -52,6 +65,41 @@ export class PasswordRandomizer {
return wordList.join(request.separator);
}
generate(
request: GenerationRequest,
settings: PasswordGenerationOptions,
): Promise<GeneratedCredential>;
generate(
request: GenerationRequest,
settings: PassphraseGenerationOptions,
): Promise<GeneratedCredential>;
async generate(
_request: GenerationRequest,
settings: PasswordGenerationOptions | PassphraseGenerationOptions,
) {
if (isPasswordGenerationOptions(settings)) {
const request = optionsToRandomAsciiRequest(settings);
const password = await this.randomAscii(request);
return new GeneratedCredential(password, "password", Date.now());
} else if (isPassphraseGenerationOptions(settings)) {
const request = optionsToEffWordListRequest(settings);
const passphrase = await this.randomEffLongWords(request);
return new GeneratedCredential(passphrase, "passphrase", Date.now());
}
throw new Error("Invalid settings received by generator.");
}
}
function isPasswordGenerationOptions(settings: any): settings is PasswordGenerationOptions {
return "length" in (settings ?? {});
}
function isPassphraseGenerationOptions(settings: any): settings is PassphraseGenerationOptions {
return "numWords" in (settings ?? {});
}
// given a generator request, convert each of its `number | undefined` properties

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -8,9 +8,14 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st
import { Constraints } from "@bitwarden/common/tools/types";
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, FakeAccountService, awaitAsync } from "../../../../../common/spec";
import { PolicyEvaluator } from "../abstractions";
import { CredentialGeneratorConfiguration } from "../types";
import {
FakeStateProvider,
FakeAccountService,
awaitAsync,
ObservableTracker,
} from "../../../../../common/spec";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { CredentialGeneratorConfiguration, GeneratedCredential } from "../types";
import { CredentialGeneratorService } from "./credential-generator.service";
@ -34,8 +39,23 @@ const somePolicy = new Policy({
enabled: true,
});
const SomeTime = new Date(1);
const SomeCategory = "passphrase";
// fake the configuration
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
category: SomeCategory,
engine: {
create: (randomizer) => {
return {
generate: (request, settings) => {
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
const result = new GeneratedCredential(credential, SomeCategory, SomeTime);
return Promise.resolve(result);
},
};
},
},
settings: {
initial: { foo: "initial" },
constraints: { foo: {} },
@ -87,6 +107,9 @@ const accountService = new FakeAccountService({
// fake state
const stateProvider = new FakeStateProvider(accountService);
// fake randomizer
const randomizer = mock<Randomizer>();
describe("CredentialGeneratorService", () => {
beforeEach(async () => {
await accountService.switchAccount(SomeUser);
@ -94,10 +117,242 @@ describe("CredentialGeneratorService", () => {
jest.clearAllMocks();
});
describe("generate$", () => {
it("emits a generation for the active user when subscribed", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("value", SomeCategory, SomeTime));
});
it("follows the active user", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await accountService.switchAccount(AnotherUser);
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime),
]);
});
it("emits a generation when the settings change", async () => {
const someSettings = { foo: "some value" };
const anotherSettings = { foo: "another value" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
await generated.pauseUntilReceived(2);
generated.unsubscribe();
expect(generated.emissions).toEqual([
new GeneratedCredential("some value", SomeCategory, SomeTime),
new GeneratedCredential("another value", SomeCategory, SomeTime),
]);
});
// FIXME: test these when the fake state provider can create the required emissions
it.todo("errors when the settings error");
it.todo("completes when the settings complete");
it("includes `website$`'s last emitted value", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("some website|value", SomeCategory, SomeTime));
});
it("errors when `website$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
let error = null;
generator.generate$(SomeConfiguration, { website$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
website$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `website$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const website$ = new BehaviorSubject("some website");
let completed = false;
generator.generate$(SomeConfiguration, { website$ }).subscribe({
complete: () => {
completed = true;
},
});
website$.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
it("emits a generation for a specific user when `user$` supplied", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
const result = await generated.expectEmission();
expect(result).toEqual(new GeneratedCredential("another", SomeCategory, SomeTime));
});
it("emits a generation for a specific user when `user$` emits", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.pipe(filter((u) => !!u));
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
userId.next(AnotherUser);
const result = await generated.pauseUntilReceived(2);
expect(result).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime),
new GeneratedCredential("another", SomeCategory, SomeTime),
]);
});
it("errors when `user$` errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser);
let error = null;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
userId$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `user$` completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser);
let completed = false;
generator.generate$(SomeConfiguration, { userId$ }).subscribe({
complete: () => {
completed = true;
},
});
userId$.complete();
await awaitAsync();
expect(completed).toBeTruthy();
});
it("emits a generation only when `on$` emits", async () => {
// This test breaks from arrange/act/assert because it is testing causality
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
const results: any[] = [];
// confirm no emission during subscription
const sub = generator
.generate$(SomeConfiguration, { on$ })
.subscribe((result) => results.push(result));
await awaitAsync();
expect(results.length).toEqual(0);
// confirm forwarded emission
on$.next();
await awaitAsync();
expect(results).toEqual([new GeneratedCredential("value", SomeCategory, SomeTime)]);
// confirm updating settings does not cause an emission
await stateProvider.setUserState(SettingsKey, { foo: "next" }, SomeUser);
await awaitAsync();
expect(results.length).toBe(1);
// confirm forwarded emission takes latest value
on$.next();
await awaitAsync();
sub.unsubscribe();
expect(results).toEqual([
new GeneratedCredential("value", SomeCategory, SomeTime),
new GeneratedCredential("next", SomeCategory, SomeTime),
]);
});
it("errors when `on$` errors", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
let error: any = null;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
error: (e: unknown) => {
error = e;
},
});
on$.error({ some: "error" });
await awaitAsync();
expect(error).toEqual({ some: "error" });
});
it("completes when `on$` completes", async () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const on$ = new Subject<void>();
let complete = false;
// confirm no emission during subscription
generator.generate$(SomeConfiguration, { on$ }).subscribe({
complete: () => {
complete = true;
},
});
on$.complete();
await awaitAsync();
expect(complete).toBeTruthy();
});
});
describe("settings$", () => {
it("defaults to the configuration's initial settings if settings aren't found", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -107,7 +362,7 @@ describe("CredentialGeneratorService", () => {
it("reads from the active user's configuration-defined storage", async () => {
const settings = { foo: "value" };
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -119,7 +374,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
@ -131,7 +386,7 @@ describe("CredentialGeneratorService", () => {
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const results: any = [];
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
@ -148,7 +403,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
@ -161,7 +416,7 @@ describe("CredentialGeneratorService", () => {
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
const anotherSettings = { foo: "another" };
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const results: any = [];
@ -180,7 +435,7 @@ describe("CredentialGeneratorService", () => {
it("errors when the arbitrary user's stream errors", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let error = null;
@ -198,7 +453,7 @@ describe("CredentialGeneratorService", () => {
it("completes when the arbitrary user's stream completes", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let completed = false;
@ -216,7 +471,7 @@ describe("CredentialGeneratorService", () => {
it("ignores repeated arbitrary user emissions", async () => {
await stateProvider.setUserState(SettingsKey, null, SomeUser);
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
let count = 0;
@ -240,7 +495,7 @@ describe("CredentialGeneratorService", () => {
describe("settings", () => {
it("writes to the user's state", async () => {
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
subject.next({ foo: "next value" });
@ -253,7 +508,7 @@ describe("CredentialGeneratorService", () => {
it("waits for the user to become available", async () => {
const singleUserId = new BehaviorSubject(null);
const singleUserId$ = singleUserId.asObservable();
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
let completed = false;
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
@ -271,7 +526,7 @@ describe("CredentialGeneratorService", () => {
describe("policy$", () => {
it("creates a disabled policy evaluator when there is no policy", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
@ -281,7 +536,7 @@ describe("CredentialGeneratorService", () => {
});
it("creates an active policy evaluator when there is a policy", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId$ = new BehaviorSubject(SomeUser).asObservable();
const policy$ = new BehaviorSubject([somePolicy]);
policyService.getAll$.mockReturnValue(policy$);
@ -293,7 +548,7 @@ describe("CredentialGeneratorService", () => {
});
it("follows policy emissions", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicySubject = new BehaviorSubject([somePolicy]);
@ -316,7 +571,7 @@ describe("CredentialGeneratorService", () => {
});
it("follows user emissions", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
@ -340,7 +595,7 @@ describe("CredentialGeneratorService", () => {
});
it("errors when the user errors", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();
const expectedError = { some: "error" };
@ -358,7 +613,7 @@ describe("CredentialGeneratorService", () => {
});
it("completes when the user completes", async () => {
const generator = new CredentialGeneratorService(stateProvider, policyService);
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
const userId = new BehaviorSubject(SomeUser);
const userId$ = userId.asObservable();

View File

@ -1,5 +1,7 @@
import {
BehaviorSubject,
combineLatest,
concatMap,
distinctUntilChanged,
endWith,
filter,
@ -8,32 +10,84 @@ import {
map,
mergeMap,
Observable,
race,
switchMap,
takeUntil,
withLatestFrom,
} from "rxjs";
import { Simplify } from "type-fest";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SingleUserDependency, UserDependency } from "@bitwarden/common/tools/dependencies";
import {
OnDependency,
SingleUserDependency,
UserDependency,
} from "@bitwarden/common/tools/dependencies";
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
import { Constraints } from "@bitwarden/common/tools/types";
import { PolicyEvaluator } from "../abstractions";
import { PolicyEvaluator, Randomizer } from "../abstractions";
import { mapPolicyToEvaluatorV2 } from "../rx";
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
type Policy$Dependencies = UserDependency;
type Settings$Dependencies = Partial<UserDependency>;
type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDependency>> & {
/** Emits the active website when subscribed.
*
* The generator does not respond to emissions of this interface;
* If it is provided, the generator blocks until a value becomes available.
* When `website$` is omitted, the generator uses the empty string instead.
* When `website$` completes, the generator completes.
* When `website$` errors, the generator forwards the error.
*/
website$?: Observable<string>;
};
// FIXME: once the modernization is complete, switch the type parameters
// in `PolicyEvaluator<P, S>` and bake-in the constraints type.
type Evaluator<Settings, Policy> = PolicyEvaluator<Policy, Settings> & Constraints<Settings>;
export class CredentialGeneratorService {
constructor(
private randomizer: Randomizer,
private stateProvider: StateProvider,
private policyService: PolicyService,
) {}
/** Generates a stream of credentials
* @param configuration determines which generator's settings are loaded
* @param dependencies.on$ when specified, a new credential is emitted when
* this emits. Otherwise, a new credential is emitted when the settings
* update.
*/
generate$<Settings, Policy>(
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies?: Generate$Dependencies,
) {
// instantiate the engine
const engine = configuration.engine.create(this.randomizer);
// stream blocks until all of these values are received
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
const request$ = website$.pipe(map((website) => ({ website })));
const settings$ = this.settings$(configuration, dependencies);
// monitor completion
const requestComplete$ = request$.pipe(ignoreElements(), endWith(true));
const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
const complete$ = race(requestComplete$, settingsComplete$);
// generation proper
const generate$ = (dependencies?.on$ ?? settings$).pipe(
withLatestFrom(request$, settings$),
concatMap(([, request, settings]) => engine.generate(request, settings)),
takeUntil(complete$),
);
return generate$;
}
/** Get the settings for the provided configuration
* @param configuration determines which generator's settings are loaded
* @param dependencies.userId$ identifies the user to which the settings are bound.
@ -82,7 +136,7 @@ export class CredentialGeneratorService {
* @remarks the subject enforces policy for the settings
*/
async settings<Settings, Policy>(
configuration: Configuration<Settings, Policy>,
configuration: Readonly<Configuration<Settings, Policy>>,
dependencies: SingleUserDependency,
) {
const userId = await firstValueFrom(

View File

@ -17,7 +17,7 @@ import { PASSPHRASE_SETTINGS } from "./storage";
const SomeUser = "some user" as UserId;
describe("Password generation strategy", () => {
describe("Passphrase generation strategy", () => {
describe("toEvaluator()", () => {
it("should map to the policy evaluator", async () => {
const strategy = new PassphraseGeneratorStrategy(null, null);

View File

@ -2,11 +2,11 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { StateProvider } from "@bitwarden/common/platform/state";
import { GeneratorStrategy } from "../abstractions";
import { DefaultPassphraseBoundaries, DefaultPassphraseGenerationOptions, Policies } from "../data";
import { DefaultPassphraseGenerationOptions, Policies } from "../data";
import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx";
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
import { observe$PerUserId, sharedStateByUserId } from "../util";
import { observe$PerUserId, optionsToEffWordListRequest, sharedStateByUserId } from "../util";
import { PASSPHRASE_SETTINGS } from "./storage";
@ -33,13 +33,7 @@ export class PassphraseGeneratorStrategy
// algorithm
async generate(options: PassphraseGenerationOptions): Promise<string> {
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
const request = {
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
};
const request = optionsToEffWordListRequest(options);
return this.randomizer.randomEffLongWords(request);
}

View File

@ -6,7 +6,7 @@ import { Policies, DefaultPasswordGenerationOptions } from "../data";
import { PasswordRandomizer } from "../engine";
import { mapPolicyToEvaluator } from "../rx";
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
import { observe$PerUserId, sharedStateByUserId, sum } from "../util";
import { observe$PerUserId, optionsToRandomAsciiRequest, sharedStateByUserId } from "../util";
import { PASSWORD_SETTINGS } from "./storage";
@ -32,62 +32,7 @@ export class PasswordGeneratorStrategy
// algorithm
async generate(options: PasswordGenerationOptions): Promise<string> {
// converts password generation option sets, which are defined by
// an "enabled" and "quantity" parameter, to the password engine's
// parameters, which represent disabled options as `undefined`
// properties.
function process(
// values read from the options
enabled: boolean,
quantity: number,
// value used if an option is missing
defaultEnabled: boolean,
defaultQuantity: number,
) {
const isEnabled = enabled ?? defaultEnabled;
const actualQuantity = quantity ?? defaultQuantity;
const result = isEnabled ? actualQuantity : undefined;
return result;
}
const request = {
uppercase: process(
options.uppercase,
options.minUppercase,
DefaultPasswordGenerationOptions.uppercase,
DefaultPasswordGenerationOptions.minUppercase,
),
lowercase: process(
options.lowercase,
options.minLowercase,
DefaultPasswordGenerationOptions.lowercase,
DefaultPasswordGenerationOptions.minLowercase,
),
digits: process(
options.number,
options.minNumber,
DefaultPasswordGenerationOptions.number,
DefaultPasswordGenerationOptions.minNumber,
),
special: process(
options.special,
options.minSpecial,
DefaultPasswordGenerationOptions.special,
DefaultPasswordGenerationOptions.minSpecial,
),
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
all: 0,
};
// engine represents character sets as "include only"; you assert how many all
// characters there can be rather than a total length. This conversion has
// the character classes win, so that the result is always consistent with policy
// minimums.
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
const remaining = (options.length ?? 0) - required;
request.all = Math.max(remaining, 0);
const request = optionsToRandomAsciiRequest(options);
const result = await this.randomizer.randomAscii(request);
return result;

View File

@ -0,0 +1,5 @@
/** Kinds of credentials that can be stored by the history service
* password - a secret consisting of arbitrary characters used to authenticate a user
* passphrase - a secret consisting of words used to authenticate a user
*/
export type CredentialCategory = "password" | "passphrase";

View File

@ -1,9 +1,29 @@
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
import { Constraints } from "@bitwarden/common/tools/types";
import { Randomizer } from "../abstractions";
import { PolicyConfiguration } from "../types";
import { CredentialCategory } from "./credential-category";
import { CredentialGenerator } from "./credential-generator";
export type CredentialGeneratorConfiguration<Settings, Policy> = {
/** Category describing usage of the credential generated by this configuration
*/
category: CredentialCategory;
/** An algorithm that generates credentials when ran. */
engine: {
/** Factory for the generator
*/
// FIXME: note that this erases the engine's type so that credentials are
// generated uniformly. This property needs to be maintained for
// the credential generator, but engine configurations should return
// the underlying type. `create` may be able to do double-duty w/ an
// engine definition if `CredentialGenerator` can be made covariant.
create: (randomizer: Randomizer) => CredentialGenerator<Settings>;
};
/** Defines the stored parameters for credential generation */
settings: {
/** value used when an account's settings haven't been initialized */
initial: Readonly<Partial<Settings>>;

View File

@ -0,0 +1,12 @@
import { GenerationRequest } from "@bitwarden/common/tools/types";
import { GeneratedCredential } from "./generated-credential";
/** An algorithm that generates credentials. */
export type CredentialGenerator<Settings> = {
/** Generates a credential
* @param request runtime parameters
* @param settings stored parameters
*/
generate: (request: GenerationRequest, settings: Settings) => Promise<GeneratedCredential>;
};

View File

@ -0,0 +1,58 @@
import { CredentialCategory, GeneratedCredential } from ".";
describe("GeneratedCredential", () => {
describe("constructor", () => {
it("assigns credential", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.credential).toEqual("example");
});
it("assigns category", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.category).toEqual("passphrase");
});
it("passes through date parameters", () => {
const result = new GeneratedCredential("example", "password", new Date(100));
expect(result.generationDate).toEqual(new Date(100));
});
it("converts numeric dates to Dates", () => {
const result = new GeneratedCredential("example", "password", 100);
expect(result.generationDate).toEqual(new Date(100));
});
});
it("toJSON converts from a credential into a JSON object", () => {
const credential = new GeneratedCredential("example", "password", new Date(100));
const result = credential.toJSON();
expect(result).toEqual({
credential: "example",
category: "password" as CredentialCategory,
generationDate: 100,
});
});
it("fromJSON converts Json objects into credentials", () => {
const jsonValue = {
credential: "example",
category: "password" as CredentialCategory,
generationDate: 100,
};
const result = GeneratedCredential.fromJSON(jsonValue);
expect(result).toBeInstanceOf(GeneratedCredential);
expect(result).toEqual({
credential: "example",
category: "password",
generationDate: new Date(100),
});
});
});

View File

@ -0,0 +1,47 @@
import { Jsonify } from "type-fest";
import { CredentialCategory } from "./credential-category";
/** A credential generation result */
export class GeneratedCredential {
/**
* Instantiates a generated credential
* @param credential The value of the generated credential (e.g. a password)
* @param category The kind of credential
* @param generationDate The date that the credential was generated.
* Numeric values should are interpreted using {@link Date.valueOf}
* semantics.
*/
constructor(
readonly credential: string,
readonly category: CredentialCategory,
generationDate: Date | number,
) {
if (typeof generationDate === "number") {
this.generationDate = new Date(generationDate);
} else {
this.generationDate = generationDate;
}
}
/** The date that the credential was generated */
generationDate: Date;
/** Constructs a credential from its `toJSON` representation */
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
return new GeneratedCredential(
jsonValue.credential,
jsonValue.category,
jsonValue.generationDate,
);
}
/** Serializes a credential to a JSON-compatible object */
toJSON() {
return {
credential: this.credential,
category: this.category,
generationDate: this.generationDate.valueOf(),
};
}
}

View File

@ -1,8 +1,11 @@
export * from "./boundary";
export * from "./catchall-generator-options";
export * from "./credential-category";
export * from "./credential-generator";
export * from "./credential-generator-configuration";
export * from "./eff-username-generator-options";
export * from "./forwarder-options";
export * from "./generated-credential";
export * from "./generator-options";
export * from "./generator-type";
export * from "./no-policy";

View File

@ -1,4 +1,5 @@
import { sum } from "./util";
import { DefaultPassphraseGenerationOptions } from "./data";
import { optionsToEffWordListRequest, optionsToRandomAsciiRequest, sum } from "./util";
describe("sum", () => {
it("returns 0 when the list is empty", () => {
@ -15,3 +16,411 @@ describe("sum", () => {
expect(sum(1, 2, 3)).toBe(6);
});
});
describe("optionsToRandomAsciiRequest", () => {
it("should map options", async () => {
const result = optionsToRandomAsciiRequest({
length: 20,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 2,
minNumber: 3,
minSpecial: 4,
});
expect(result).toEqual({
all: 10,
uppercase: 1,
lowercase: 2,
digits: 3,
special: 4,
ambiguous: true,
});
});
it("should disable uppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: false,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: undefined,
lowercase: 1,
digits: 1,
special: 1,
ambiguous: true,
});
});
it("should disable lowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: false,
number: true,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: undefined,
digits: 1,
special: 1,
ambiguous: true,
});
});
it("should disable digits", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: true,
number: false,
special: true,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 1,
digits: undefined,
special: 1,
ambiguous: true,
});
});
it("should disable special", async () => {
const result = optionsToRandomAsciiRequest({
length: 3,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: false,
minUppercase: 1,
minLowercase: 1,
minNumber: 1,
minSpecial: 1,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 1,
digits: 1,
special: undefined,
ambiguous: true,
});
});
it("should override length with minimums", async () => {
const result = optionsToRandomAsciiRequest({
length: 20,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 1,
minLowercase: 2,
minNumber: 3,
minSpecial: 4,
});
expect(result).toEqual({
all: 10,
uppercase: 1,
lowercase: 2,
digits: 3,
special: 4,
ambiguous: true,
});
});
it("should default uppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 2,
ambiguous: true,
lowercase: true,
number: true,
special: true,
minUppercase: 2,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 2,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default lowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 2,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 2,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default number", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 2,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 2,
special: 0,
ambiguous: true,
});
});
it("should default special", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 0,
special: undefined,
ambiguous: true,
});
});
it("should default minUppercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minLowercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 1,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default minLowercase", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minNumber: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 1,
digits: 0,
special: 0,
ambiguous: true,
});
});
it("should default minNumber", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minSpecial: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 1,
special: 0,
ambiguous: true,
});
});
it("should default minSpecial", async () => {
const result = optionsToRandomAsciiRequest({
length: 0,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
special: true,
minUppercase: 0,
minLowercase: 0,
minNumber: 0,
});
expect(result).toEqual({
all: 0,
uppercase: 0,
lowercase: 0,
digits: 0,
special: 0,
ambiguous: true,
});
});
});
describe("optionsToEffWordListRequest", () => {
it("should map options", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: true,
separator: "!",
});
});
it("should default numWords", async () => {
const result = optionsToEffWordListRequest({
capitalize: true,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: DefaultPassphraseGenerationOptions.numWords,
capitalize: true,
number: true,
separator: "!",
});
});
it("should default capitalize", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
includeNumber: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: DefaultPassphraseGenerationOptions.capitalize,
number: true,
separator: "!",
});
});
it("should default includeNumber", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
wordSeparator: "!",
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: DefaultPassphraseGenerationOptions.includeNumber,
separator: "!",
});
});
it("should default wordSeparator", async () => {
const result = optionsToEffWordListRequest({
numWords: 4,
capitalize: true,
includeNumber: true,
});
expect(result).toEqual({
numberOfWords: 4,
capitalize: true,
number: true,
separator: DefaultPassphraseGenerationOptions.wordSeparator,
});
});
});

View File

@ -7,6 +7,13 @@ import {
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
DefaultPassphraseBoundaries,
DefaultPassphraseGenerationOptions,
DefaultPasswordGenerationOptions,
} from "./data";
import { PassphraseGenerationOptions, PasswordGenerationOptions } from "./types";
/** construct a method that outputs a copy of `defaultValue` as an observable. */
export function observe$PerUserId<Value>(
create: () => Partial<Value>,
@ -50,3 +57,79 @@ export function sharedStateByUserId<Value>(key: UserKeyDefinition<Value>, provid
/** returns the sum of items in the list. */
export const sum = (...items: number[]) =>
(items ?? []).reduce((sum: number, current: number) => sum + (current ?? 0), 0);
/* converts password generation option sets, which are defined by
* an "enabled" and "quantity" parameter, to the password engine's
* parameters, which represent disabled options as `undefined`
* properties.
*/
export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions) {
// helper for processing common option sets
function process(
// values read from the options
enabled: boolean,
quantity: number,
// value used if an option is missing
defaultEnabled: boolean,
defaultQuantity: number,
) {
const isEnabled = enabled ?? defaultEnabled;
const actualQuantity = quantity ?? defaultQuantity;
const result = isEnabled ? actualQuantity : undefined;
return result;
}
const request = {
uppercase: process(
options.uppercase,
options.minUppercase,
DefaultPasswordGenerationOptions.uppercase,
DefaultPasswordGenerationOptions.minUppercase,
),
lowercase: process(
options.lowercase,
options.minLowercase,
DefaultPasswordGenerationOptions.lowercase,
DefaultPasswordGenerationOptions.minLowercase,
),
digits: process(
options.number,
options.minNumber,
DefaultPasswordGenerationOptions.number,
DefaultPasswordGenerationOptions.minNumber,
),
special: process(
options.special,
options.minSpecial,
DefaultPasswordGenerationOptions.special,
DefaultPasswordGenerationOptions.minSpecial,
),
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
all: 0,
};
// engine represents character sets as "include only"; you assert how many all
// characters there can be rather than a total length. This conversion has
// the character classes win, so that the result is always consistent with policy
// minimums.
const required = sum(request.uppercase, request.lowercase, request.digits, request.special);
const remaining = (options.length ?? 0) - required;
request.all = Math.max(remaining, 0);
return request;
}
/* converts passphrase generation option sets to the eff word list request
*/
export function optionsToEffWordListRequest(options: PassphraseGenerationOptions) {
const requestWords = options.numWords ?? DefaultPassphraseGenerationOptions.numWords;
const request = {
numberOfWords: Math.max(requestWords, DefaultPassphraseBoundaries.numWords.min),
capitalize: options.capitalize ?? DefaultPassphraseGenerationOptions.capitalize,
number: options.includeNumber ?? DefaultPassphraseGenerationOptions.includeNumber,
separator: options.wordSeparator ?? DefaultPassphraseGenerationOptions.wordSeparator,
};
return request;
}

41
package-lock.json generated
View File

@ -40,7 +40,7 @@
"commander": "11.1.0",
"core-js": "3.36.1",
"form-data": "4.0.0",
"https-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.5",
"inquirer": "8.2.6",
"jquery": "3.7.1",
"jsdom": "24.1.3",
@ -129,7 +129,7 @@
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"electron": "32.0.1",
"electron": "32.0.2",
"electron-builder": "24.13.3",
"electron-log": "5.0.1",
"electron-reload": "2.0.0-alpha.1",
@ -157,7 +157,7 @@
"jest-mock-extended": "3.0.7",
"jest-preset-angular": "14.1.1",
"lint-staged": "15.2.8",
"mini-css-extract-plugin": "2.8.1",
"mini-css-extract-plugin": "2.9.1",
"node-ipc": "9.2.1",
"postcss": "8.4.38",
"postcss-loader": "8.1.1",
@ -192,7 +192,7 @@
},
"apps/browser": {
"name": "@bitwarden/browser",
"version": "2024.9.0"
"version": "2024.9.1"
},
"apps/cli": {
"name": "@bitwarden/cli",
@ -207,7 +207,7 @@
"chalk": "4.1.2",
"commander": "11.1.0",
"form-data": "4.0.0",
"https-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.5",
"inquirer": "8.2.6",
"jsdom": "24.1.3",
"jszip": "3.10.1",
@ -16433,9 +16433,9 @@
}
},
"node_modules/electron": {
"version": "32.0.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-32.0.1.tgz",
"integrity": "sha512-5Hd5Jaf9niYVR2hZxoRd3gOrcxPOxQV1XPV5WaoSfT9jLJHFadhlKtuSDIk3U6rQZke+aC7GqPPAv55nWFCMsA==",
"version": "32.0.2",
"resolved": "https://registry.npmjs.org/electron/-/electron-32.0.2.tgz",
"integrity": "sha512-nmZblq8wW3HZ17MAyaUuiMI9Mb0Cgc7UR3To85h/rVopbfyF5s34NxtK4gvyRfYPxpDGP4k+HoQIPniPPrdE3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -21637,9 +21637,9 @@
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.2.tgz",
"integrity": "sha512-NmLNjm6ucYwtcUmL7JQC1ZQ57LmHP4lT15FQ8D61nak1rO6DH+fz5qNK2Ap5UN4ZapYICE3/0KodcLYSPsPbaA==",
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.0.2",
@ -24733,19 +24733,6 @@
"node": ">= 14"
}
},
"node_modules/jsdom/node_modules/https-proxy-agent": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz",
"integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.0.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
@ -27721,9 +27708,9 @@
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.8.1.tgz",
"integrity": "sha512-/1HDlyFRxWIZPI1ZpgqlZ8jMw/1Dp/dl3P0L1jtZ+zVcHqwPhGwaJwKL00WVgfnBy6PWCde9W65or7IIETImuA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.1.tgz",
"integrity": "sha512-+Vyi+GCCOHnrJ2VPS+6aPoXN2k2jgUzDRhTFLjjTBn23qyXJXkjUWQgTL+mXpF5/A8ixLdCc6kWsoeOjKGejKQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -91,7 +91,7 @@
"copy-webpack-plugin": "12.0.2",
"cross-env": "7.0.3",
"css-loader": "7.1.2",
"electron": "32.0.1",
"electron": "32.0.2",
"electron-builder": "24.13.3",
"electron-log": "5.0.1",
"electron-reload": "2.0.0-alpha.1",
@ -119,7 +119,7 @@
"jest-mock-extended": "3.0.7",
"jest-preset-angular": "14.1.1",
"lint-staged": "15.2.8",
"mini-css-extract-plugin": "2.8.1",
"mini-css-extract-plugin": "2.9.1",
"node-ipc": "9.2.1",
"postcss": "8.4.38",
"postcss-loader": "8.1.1",
@ -173,7 +173,7 @@
"commander": "11.1.0",
"core-js": "3.36.1",
"form-data": "4.0.0",
"https-proxy-agent": "7.0.2",
"https-proxy-agent": "7.0.5",
"inquirer": "8.2.6",
"jquery": "3.7.1",
"jsdom": "24.1.3",