From 700acc069b0b8d817533f9b57c90a6c947c5260b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Jun 2024 10:24:14 -0400 Subject: [PATCH 01/17] [deps] Autofill: Update tldts to v6.1.25 (#9559) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/cli/package.json | 2 +- package-lock.json | 20 +++++++++++--------- package.json | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 1ad09cc17a..e992c3b672 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -80,7 +80,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.22", + "tldts": "6.1.25", "zxcvbn": "4.4.2" } } diff --git a/package-lock.json b/package-lock.json index c5db91b848..fec3db2aea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.22", + "tldts": "6.1.25", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2" @@ -228,7 +228,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.22", + "tldts": "6.1.25", "zxcvbn": "4.4.2" }, "bin": { @@ -37431,20 +37431,22 @@ "dev": true }, "node_modules/tldts": { - "version": "6.1.22", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.22.tgz", - "integrity": "sha512-WwWAPp+zJn8oJkpjqJcSuuj5foL9cI8SiTjH+gGS1bw5N163YywM0Cmd9OijwtKjdGG7OC6NEYZVl4EG8HfSMg==", + "version": "6.1.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.25.tgz", + "integrity": "sha512-UmjB1dVArio9hny1D84VFeEvE37nCyfW5sWHr7AUV2MxJgxD8NR/kdmEMyjx5o/kRuOOBbaaXStce2R5C6I1Gg==", + "license": "MIT", "dependencies": { - "tldts-core": "^6.1.22" + "tldts-core": "^6.1.25" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.22", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.22.tgz", - "integrity": "sha512-TMCyBC7HpvDpBRQCLsODmsclNXGhZLSj76gIlx7QcwvKElMdIzhGN5iYcuTI7yAWJm8zTpsVehWCeOGytDY9fg==" + "version": "6.1.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.25.tgz", + "integrity": "sha512-hbSsjJOeDMV91JiqcrrFQ46D7EepH880zVmPjnBDmt3P+h0Aowz8Nh1adIcqkdhJbgpzZYQr6aM8/N3tZC6JyA==", + "license": "MIT" }, "node_modules/tmp": { "version": "0.0.33", diff --git a/package.json b/package.json index 9670da6f67..93a6b51280 100644 --- a/package.json +++ b/package.json @@ -204,7 +204,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.22", + "tldts": "6.1.25", "utf-8-validate": "6.0.3", "zone.js": "0.13.3", "zxcvbn": "4.4.2" From 7fb94082024f15ad5cd1c52be633663f3f1924ee Mon Sep 17 00:00:00 2001 From: Dillon Beresford <165616268+bwdil@users.noreply.github.com> Date: Mon, 10 Jun 2024 10:54:24 -0500 Subject: [PATCH 02/17] [PM-7025] include check-run in workflows where secrets are used (#9135) * include check-run in workflows where secrets are used * revert changes in build-cli workflow and add check-run to codecov * assert token permissions --------- Co-authored-by: Matt Bishop --- .github/workflows/test.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12649b91ea..cb4a18947b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,16 +8,26 @@ on: - "main" - "rc" - "hotfix-rc-*" - pull_request: + pull_request_target: + types: [opened, synchronize] defaults: run: shell: bash jobs: + check-run: + name: Check PR run + uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main + test: name: Run tests runs-on: ubuntu-22.04 + needs: check-run + permissions: + contents: read + pull-requests: write + steps: - name: Checkout repo uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 From 19f2d2aefc3cd411511638f6c80c4695580b030e Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 10 Jun 2024 09:55:12 -0700 Subject: [PATCH 03/17] [PM-8379] Update vault popup items service to track loading state (#9528) --- .../vault-popup-items.service.spec.ts | 48 +++++++++++++++++++ .../services/vault-popup-items.service.ts | 35 ++++++++++---- libs/common/spec/observable-tracker.ts | 9 ++-- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts index b7091eb87b..f08f4e836e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.spec.ts @@ -379,6 +379,54 @@ describe("VaultPopupItemsService", () => { }); }); + describe("loading$", () => { + let tracked: ObservableTracker; + let trackedCiphers: ObservableTracker; + beforeEach(() => { + // Start tracking loading$ emissions + tracked = new ObservableTracker(service.loading$); + + // Track remainingCiphers$ to make cipher observables active + trackedCiphers = new ObservableTracker(service.remainingCiphers$); + }); + + it("should initialize with true first", async () => { + expect(tracked.emissions[0]).toBe(true); + }); + + it("should emit false once ciphers are available", async () => { + expect(tracked.emissions.length).toBe(2); + expect(tracked.emissions[0]).toBe(true); + expect(tracked.emissions[1]).toBe(false); + }); + + it("should cycle when cipherService.ciphers$ emits", async () => { + // Restart tracking + tracked = new ObservableTracker(service.loading$); + (cipherServiceMock.ciphers$ as BehaviorSubject).next(null); + + await trackedCiphers.pauseUntilReceived(2); + + expect(tracked.emissions.length).toBe(3); + expect(tracked.emissions[0]).toBe(false); + expect(tracked.emissions[1]).toBe(true); + expect(tracked.emissions[2]).toBe(false); + }); + + it("should cycle when filters are applied", async () => { + // Restart tracking + tracked = new ObservableTracker(service.loading$); + service.applyFilter("test"); + + await trackedCiphers.pauseUntilReceived(2); + + expect(tracked.emissions.length).toBe(3); + expect(tracked.emissions[0]).toBe(false); + expect(tracked.emissions[1]).toBe(true); + expect(tracked.emissions[2]).toBe(false); + }); + }); + describe("applyFilter", () => { it("should call search Service with the new search term", (done) => { const searchText = "Hello"; diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 189ce2c09f..f96bb095b9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable, NgZone } from "@angular/core"; import { BehaviorSubject, combineLatest, + distinctUntilChanged, distinctUntilKeyChanged, from, map, @@ -12,6 +13,8 @@ import { startWith, Subject, switchMap, + tap, + withLatestFrom, } from "rxjs"; import { SearchService } from "@bitwarden/common/abstractions/search.service"; @@ -40,6 +43,13 @@ import { MY_VAULT_ID, VaultPopupListFiltersService } from "./vault-popup-list-fi export class VaultPopupItemsService { private _refreshCurrentTab$ = new Subject(); private _searchText$ = new BehaviorSubject(""); + + /** + * Subject that emits whenever new ciphers are being processed/filtered. + * @private + */ + private _ciphersLoading$ = new Subject(); + latestSearchText$: Observable = this._searchText$.asObservable(); /** @@ -84,6 +94,7 @@ export class VaultPopupItemsService { this.cipherService.localData$, ).pipe( runInsideAngular(inject(NgZone)), // Workaround to ensure cipher$ state provider emissions are run inside Angular + tap(() => this._ciphersLoading$.next()), switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())), switchMap((ciphers) => combineLatest([ @@ -112,6 +123,7 @@ export class VaultPopupItemsService { this._searchText$, this.vaultPopupListFiltersService.filterFunction$, ]).pipe( + tap(() => this._ciphersLoading$.next()), map(([ciphers, searchText, filterFunction]): [CipherView[], string] => [ filterFunction(ciphers), searchText, @@ -148,10 +160,8 @@ export class VaultPopupItemsService { * List of favorite ciphers that are not currently suggested for autofill. * Ciphers are sorted by last used date, then by name. */ - favoriteCiphers$: Observable = combineLatest([ - this.autoFillCiphers$, - this._filteredCipherList$, - ]).pipe( + favoriteCiphers$: Observable = this.autoFillCiphers$.pipe( + withLatestFrom(this._filteredCipherList$), map(([autoFillCiphers, ciphers]) => ciphers.filter((cipher) => cipher.favorite && !autoFillCiphers.includes(cipher)), ), @@ -165,12 +175,9 @@ export class VaultPopupItemsService { * List of all remaining ciphers that are not currently suggested for autofill or marked as favorite. * Ciphers are sorted by name. */ - remainingCiphers$: Observable = combineLatest([ - this.autoFillCiphers$, - this.favoriteCiphers$, - this._filteredCipherList$, - ]).pipe( - map(([autoFillCiphers, favoriteCiphers, ciphers]) => + remainingCiphers$: Observable = this.favoriteCiphers$.pipe( + withLatestFrom(this._filteredCipherList$, this.autoFillCiphers$), + map(([favoriteCiphers, ciphers, autoFillCiphers]) => ciphers.filter( (cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher), ), @@ -179,6 +186,14 @@ export class VaultPopupItemsService { shareReplay({ refCount: false, bufferSize: 1 }), ); + /** + * Observable that indicates whether the service is currently loading ciphers. + */ + loading$: Observable = merge( + this._ciphersLoading$.pipe(map(() => true)), + this.remainingCiphers$.pipe(map(() => false)), + ).pipe(startWith(true), distinctUntilChanged(), shareReplay({ refCount: false, bufferSize: 1 })); + /** * Observable that indicates whether a filter is currently applied to the ciphers. */ diff --git a/libs/common/spec/observable-tracker.ts b/libs/common/spec/observable-tracker.ts index 9bf0475bee..dfb4983593 100644 --- a/libs/common/spec/observable-tracker.ts +++ b/libs/common/spec/observable-tracker.ts @@ -1,4 +1,4 @@ -import { Observable, Subject, Subscription, firstValueFrom, throwError, timeout } from "rxjs"; +import { firstValueFrom, Observable, Subject, Subscription, throwError, timeout } from "rxjs"; /** Test class to enable async awaiting of observable emissions */ export class ObservableTracker { @@ -43,6 +43,9 @@ export class ObservableTracker { private trackEmissions(observable: Observable): T[] { const emissions: T[] = []; + this.emissionReceived.subscribe((value) => { + emissions.push(value); + }); this.subscription = observable.subscribe((value) => { if (value == null) { this.emissionReceived.next(null); @@ -64,9 +67,7 @@ export class ObservableTracker { } } }); - this.emissionReceived.subscribe((value) => { - emissions.push(value); - }); + return emissions; } } From b169207b74ee593365a82b1b73c6d0241a21928e Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 10 Jun 2024 11:59:20 -0500 Subject: [PATCH 04/17] [AC-2647] Remove Flexible Collections MVP code (#9518) * chore: organization.ts, remove refs to flexibleCollections and isManager, refs AC-2647 * chore: clean up callers of removed methods from organization.ts, refs AC-2647 * chore: access-selector, remove fc input and update permissionList param, refs AC-2647 * chore: update permissionList caller, update group-add-edit fc refs, and remove accessAll, refs AC-2647 * chore: update member-dialog fc callers, refs AC-2647 * chore: update bulk-collections-dialog fc callers, refs AC-2647 * chore: update collection-dialog fc callers, refs AC-2647 * chore: update simple fc caller to misc files, refs AC-2647 * chore: update member-dialog fc callers, refs AC-2647 * chore: remove accessAll references and update callers, refs AC-2647 * chore: update comment to specify v1 usage, refs AC-2647 * chore: remove unused message keys and code calls to use those messages, refs AC-2647 * chore: remove readonly false from access-selector model map function, refs AC-2647 --- .../core/services/group/group.service.ts | 1 - .../services/group/requests/group.request.ts | 1 - .../group/responses/group.response.ts | 6 - .../core/services/user-admin.service.ts | 3 - .../organizations/core/views/group.view.ts | 6 - .../views/organization-user-admin-view.ts | 6 - .../core/views/organization-user.view.ts | 6 - .../organization-layout.component.html | 2 +- .../manage/group-add-edit.component.html | 27 +-- .../manage/group-add-edit.component.ts | 13 +- .../manage/groups.component.html | 2 - .../member-dialog.component.html | 217 +++++------------- .../member-dialog/member-dialog.component.ts | 26 +-- .../members/people.component.html | 2 - .../settings/account.component.html | 2 +- .../access-selector.component.html | 9 - .../access-selector.component.ts | 20 +- .../access-selector/access-selector.models.ts | 16 +- .../access-selector.stories.ts | 3 - .../access-selector/user-type.pipe.ts | 2 - .../collection-dialog.component.html | 4 +- .../collection-dialog.component.ts | 14 +- .../vault-collection-row.component.ts | 2 +- .../vault-filter-section.component.ts | 14 +- .../bulk-collections-dialog.component.html | 2 - .../bulk-collections-dialog.component.ts | 6 +- .../vault-filter/vault-filter.component.ts | 6 +- .../vault-header/vault-header.component.html | 5 +- .../vault-header/vault-header.component.ts | 4 +- .../app/vault/org-vault/vault.component.html | 4 +- .../app/vault/org-vault/vault.component.ts | 2 +- apps/web/src/locales/en/messages.json | 27 --- libs/angular/src/pipes/user-type.pipe.ts | 2 - .../organization-user-invite.request.ts | 1 - .../organization-user-update.request.ts | 1 - .../responses/organization-user.response.ts | 6 - .../organization.service.abstraction.ts | 7 +- .../models/domain/organization.ts | 55 +---- .../src/vault/models/view/collection.view.ts | 14 +- .../src/components/import.component.ts | 7 +- .../src/components/export.component.ts | 6 +- 41 files changed, 106 insertions(+), 453 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts index 63431cd6ab..e06a9aa8dc 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/group/group.service.ts @@ -80,7 +80,6 @@ export class InternalGroupService extends GroupService { async save(group: GroupView): Promise { const request = new GroupRequest(); request.name = group.name; - request.accessAll = group.accessAll; request.users = group.members; request.collections = group.collections.map( (c) => new SelectionReadOnlyRequest(c.id, c.readOnly, c.hidePasswords, c.manage), diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts b/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts index b59c869692..40f253d945 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/group/requests/group.request.ts @@ -2,7 +2,6 @@ import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models export class GroupRequest { name: string; - accessAll: boolean; collections: SelectionReadOnlyRequest[] = []; users: string[] = []; } diff --git a/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts b/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts index e969de4ad1..eb62d83712 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/group/responses/group.response.ts @@ -5,11 +5,6 @@ export class GroupResponse extends BaseResponse { id: string; organizationId: string; name: string; - /** - * @deprecated - * To be removed after Flexible Collections. - **/ - accessAll: boolean; externalId: string; constructor(response: any) { @@ -17,7 +12,6 @@ export class GroupResponse extends BaseResponse { this.id = this.getResponseProperty("Id"); this.organizationId = this.getResponseProperty("OrganizationId"); this.name = this.getResponseProperty("Name"); - this.accessAll = this.getResponseProperty("AccessAll"); this.externalId = this.getResponseProperty("ExternalId"); } } diff --git a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts index 399140e3ea..52a522c89d 100644 --- a/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts +++ b/apps/web/src/app/admin-console/organizations/core/services/user-admin.service.ts @@ -41,7 +41,6 @@ export class UserAdminService { async save(user: OrganizationUserAdminView): Promise { const request = new OrganizationUserUpdateRequest(); - request.accessAll = user.accessAll; request.permissions = user.permissions; request.type = user.type; request.collections = user.collections; @@ -54,7 +53,6 @@ export class UserAdminService { async invite(emails: string[], user: OrganizationUserAdminView): Promise { const request = new OrganizationUserInviteRequest(); request.emails = emails; - request.accessAll = user.accessAll; request.permissions = user.permissions; request.type = user.type; request.collections = user.collections; @@ -77,7 +75,6 @@ export class UserAdminService { view.type = u.type; view.status = u.status; view.externalId = u.externalId; - view.accessAll = u.accessAll; view.permissions = u.permissions; view.resetPasswordEnrolled = u.resetPasswordEnrolled; view.collections = u.collections.map((c) => ({ diff --git a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts index 25864cca34..1909b9a863 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/group.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/group.view.ts @@ -8,12 +8,6 @@ export class GroupView implements View { id: string; organizationId: string; name: string; - /** - * @deprecated - * To be removed after Flexible Collections. - * This will always return `false` if Flexible Collections is enabled. - **/ - accessAll: boolean; externalId: string; collections: CollectionAccessSelectionView[] = []; members: string[] = []; diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts index b4241826b3..97e77d8543 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user-admin-view.ts @@ -13,12 +13,6 @@ export class OrganizationUserAdminView { type: OrganizationUserType; status: OrganizationUserStatusType; externalId: string; - /** - * @deprecated - * To be removed after Flexible Collections. - * This will always return `false` if Flexible Collections is enabled. - **/ - accessAll: boolean; permissions: PermissionsApi; resetPasswordEnrolled: boolean; hasMasterPassword: boolean; diff --git a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts index 947ae9b13e..86d1f4ded6 100644 --- a/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts +++ b/apps/web/src/app/admin-console/organizations/core/views/organization-user.view.ts @@ -12,12 +12,6 @@ export class OrganizationUserView { userId: string; type: OrganizationUserType; status: OrganizationUserStatusType; - /** - * @deprecated - * To be removed after Flexible Collections. - * This will always return `false` if Flexible Collections is enabled. - **/ - accessAll: boolean; permissions: PermissionsApi; resetPasswordEnrolled: boolean; name: string; diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index 237e2c6e30..445a0855c1 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -11,7 +11,7 @@ diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html index 166467ada0..eaf10405db 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.html @@ -45,7 +45,6 @@ [columnHeader]="'member' | i18n" [selectorLabelText]="'selectMembers' | i18n" [emptySelectionText]="'noMembersAdded' | i18n" - [flexibleCollectionsEnabled]="flexibleCollectionsEnabled$ | async" > @@ -56,24 +55,14 @@ {{ "restrictedCollectionAssignmentDesc" | i18n }}

-
- - -

{{ "accessAllCollectionsHelp" | i18n }}

-
- - - + diff --git a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts index 38ef002534..8df770686f 100644 --- a/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts +++ b/apps/web/src/app/admin-console/organizations/manage/group-add-edit.component.ts @@ -96,9 +96,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { private organization$ = this.organizationService .get$(this.organizationId) .pipe(shareReplay({ refCount: true })); - protected flexibleCollectionsEnabled$ = this.organization$.pipe( - map((o) => o?.flexibleCollections), - ); private flexibleCollectionsV1Enabled$ = this.configService.getFeatureFlag$( FeatureFlag.FlexibleCollectionsV1, ); @@ -114,7 +111,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { group: GroupView; groupForm = this.formBuilder.group({ - accessAll: [false], name: ["", [Validators.required, Validators.maxLength(100)]], externalId: this.formBuilder.control({ value: "", disabled: true }), members: [[] as AccessItemValue[]], @@ -188,7 +184,7 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { this.flexibleCollectionsV1Enabled$, ]).pipe( map(([organization, flexibleCollectionsV1Enabled]) => { - if (!flexibleCollectionsV1Enabled || !organization.flexibleCollections) { + if (!flexibleCollectionsV1Enabled) { return true; } @@ -276,7 +272,6 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { this.groupForm.patchValue({ name: this.group.name, externalId: this.group.externalId, - accessAll: this.group.accessAll, members: this.group.members.map((m) => ({ id: m, type: AccessItemType.Member, @@ -328,12 +323,8 @@ export class GroupAddEditComponent implements OnInit, OnDestroy { const formValue = this.groupForm.value; groupView.name = formValue.name; - groupView.accessAll = formValue.accessAll; groupView.members = formValue.members?.map((m) => m.id) ?? []; - - if (!groupView.accessAll) { - groupView.collections = formValue.collections.map((c) => convertToSelectionView(c)); - } + groupView.collections = formValue.collections.map((c) => convertToSelectionView(c)); await this.groupService.save(groupView); diff --git a/apps/web/src/app/admin-console/organizations/manage/groups.component.html b/apps/web/src/app/admin-console/organizations/manage/groups.component.html index f256c29b05..1a1a7cdb90 100644 --- a/apps/web/src/app/admin-console/organizations/manage/groups.component.html +++ b/apps/web/src/app/admin-console/organizations/manage/groups.component.html @@ -74,12 +74,10 @@ - {{ "all" | i18n }} + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts new file mode 100644 index 0000000000..a3fad87c1b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/add-edit/add-edit-v2.component.ts @@ -0,0 +1,64 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { SearchModule, ButtonModule } from "@bitwarden/components"; + +import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component"; +import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component"; +import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component"; + +@Component({ + selector: "app-add-edit-v2", + templateUrl: "add-edit-v2.component.html", + standalone: true, + imports: [ + CommonModule, + SearchModule, + JslibModule, + FormsModule, + ButtonModule, + PopupPageComponent, + PopupHeaderComponent, + PopupFooterComponent, + ], +}) +export class AddEditV2Component { + headerText: string; + + constructor( + private route: ActivatedRoute, + private i18nService: I18nService, + ) { + this.subscribeToParams(); + } + + subscribeToParams(): void { + this.route.queryParams.pipe(takeUntilDestroyed()).subscribe((params) => { + const isNew = params.isNew.toLowerCase() === "true"; + const cipherType = parseInt(params.type); + + this.headerText = this.setHeader(isNew, cipherType); + }); + } + + setHeader(isNew: boolean, type: CipherType) { + const partOne = isNew ? "newItemHeader" : "editItemHeader"; + + switch (type) { + case CipherType.Login: + return this.i18nService.t(partOne, this.i18nService.t("typeLogin")); + case CipherType.Card: + return this.i18nService.t(partOne, this.i18nService.t("typeCard")); + case CipherType.Identity: + return this.i18nService.t(partOne, this.i18nService.t("typeIdentity")); + case CipherType.SecureNote: + return this.i18nService.t(partOne, this.i18nService.t("note")); + } + } +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html new file mode 100644 index 0000000000..0bd85c2169 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.html @@ -0,0 +1,22 @@ + + + + + {{ "typeLogin" | i18n }} + + + + {{ "typeCard" | i18n }} + + + + {{ "typeIdentity" | i18n }} + + + + {{ "note" | i18n }} + + diff --git a/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts new file mode 100644 index 0000000000..e90afec538 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/new-item-dropdown/new-item-dropdown-v2.component.ts @@ -0,0 +1,28 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { Router, RouterLink } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { ButtonModule, NoItemsModule, MenuModule } from "@bitwarden/components"; + +@Component({ + selector: "app-new-item-dropdown", + templateUrl: "new-item-dropdown-v2.component.html", + standalone: true, + imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], +}) +export class NewItemDropdownV2Component implements OnInit, OnDestroy { + cipherType = CipherType; + + constructor(private router: Router) {} + + ngOnInit(): void {} + + ngOnDestroy(): void {} + + // TODO PM-6826: add selectedVault query param + newItemNavigate(type: CipherType) { + void this.router.navigate(["/add-cipher"], { queryParams: { type: type, isNew: true } }); + } +} diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html index 694c0e9be5..7dd0631015 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.html @@ -1,11 +1,8 @@ - - - - {{ "new" | i18n }} - + + @@ -18,9 +15,7 @@ {{ "yourVaultIsEmpty" | i18n }} {{ "autofillSuggestionsTip" | i18n }} - + diff --git a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts index f6f6872c1c..9939727806 100644 --- a/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault/vault-v2.component.ts @@ -5,6 +5,7 @@ import { Router, RouterLink } from "@angular/router"; import { combineLatest } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components"; import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component"; @@ -13,6 +14,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component"; import { VaultPopupItemsService } from "../../services/vault-popup-items.service"; import { AutofillVaultListItemsComponent, VaultListItemsContainerComponent } from "../vault-v2"; +import { NewItemDropdownV2Component } from "../vault-v2/new-item-dropdown/new-item-dropdown-v2.component"; import { VaultListFiltersComponent } from "../vault-v2/vault-list-filters/vault-list-filters.component"; import { VaultV2SearchComponent } from "../vault-v2/vault-search/vault-v2-search.component"; @@ -40,9 +42,11 @@ enum VaultState { ButtonModule, RouterLink, VaultV2SearchComponent, + NewItemDropdownV2Component, ], }) export class VaultV2Component implements OnInit, OnDestroy { + cipherType = CipherType; protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$; protected remainingCiphers$ = this.vaultPopupItemsService.remainingCiphers$; @@ -86,9 +90,4 @@ export class VaultV2Component implements OnInit, OnDestroy { ngOnInit(): void {} ngOnDestroy(): void {} - - addCipher() { - // TODO: Add currently filtered organization to query params if available - void this.router.navigate(["/add-cipher"], {}); - } } diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index a225db0c11..211bd8fc09 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -198,7 +198,9 @@ export class ViewComponent extends BaseViewComponent { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/edit-cipher"], { queryParams: { cipherId: this.cipher.id } }); + this.router.navigate(["/edit-cipher"], { + queryParams: { cipherId: this.cipher.id, type: this.cipher.type, isNew: false }, + }); return true; } From d594b680f927003c62b901d4a57350ace467c155 Mon Sep 17 00:00:00 2001 From: Dillon Beresford <165616268+bwdil@users.noreply.github.com> Date: Mon, 10 Jun 2024 13:24:56 -0500 Subject: [PATCH 06/17] [PM-7025] Add permission for test results (#9569) * include check-run in workflows where secrets are used * revert changes in build-cli workflow and add check-run to codecov * assert token permissions * include required permissions * re-arrange permissions in alphabetical order --------- Co-authored-by: Matt Bishop --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb4a18947b..7d841ca880 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,7 @@ jobs: runs-on: ubuntu-22.04 needs: check-run permissions: + checks: write contents: read pull-requests: write From cbc34950fb9a52f4c9b64ba96d3a5abaad4fea41 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 10 Jun 2024 14:25:21 -0500 Subject: [PATCH 07/17] add check for PersonalOwnershipPolicy in vault filters (#9570) --- .../vault-popup-list-filters.service.spec.ts | 72 +++++++++++++++ .../vault-popup-list-filters.service.ts | 92 ++++++++++++------- 2 files changed, 129 insertions(+), 35 deletions(-) diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 907ff9af8d..b89de79a20 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -3,6 +3,8 @@ import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, skipWhile } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -23,6 +25,7 @@ describe("VaultPopupListFiltersService", () => { const folderViews$ = new BehaviorSubject([]); const cipherViews$ = new BehaviorSubject({}); const decryptedCollections$ = new BehaviorSubject([]); + const policyAppliesToActiveUser$ = new BehaviorSubject(false); const collectionService = { decryptedCollections$, @@ -45,9 +48,15 @@ describe("VaultPopupListFiltersService", () => { t: (key: string) => key, } as I18nService; + const policyService = { + policyAppliesToActiveUser$: jest.fn(() => policyAppliesToActiveUser$), + }; + beforeEach(() => { memberOrganizations$.next([]); decryptedCollections$.next([]); + policyAppliesToActiveUser$.next(false); + policyService.policyAppliesToActiveUser$.mockClear(); collectionService.getAllNested = () => Promise.resolve([]); TestBed.configureTestingModule({ @@ -72,6 +81,10 @@ describe("VaultPopupListFiltersService", () => { provide: CollectionService, useValue: collectionService, }, + { + provide: PolicyService, + useValue: policyService, + }, { provide: FormBuilder, useClass: FormBuilder }, ], }); @@ -127,6 +140,65 @@ describe("VaultPopupListFiltersService", () => { }); }); + describe("PersonalOwnership policy", () => { + it('calls policyAppliesToActiveUser$ with "PersonalOwnership"', () => { + expect(policyService.policyAppliesToActiveUser$).toHaveBeenCalledWith( + PolicyType.PersonalOwnership, + ); + }); + + it("returns an empty array when the policy applies and there is a single organization", (done) => { + policyAppliesToActiveUser$.next(true); + memberOrganizations$.next([ + { name: "bobby's org", id: "1234-3323-23223" }, + ] as Organization[]); + + service.organizations$.subscribe((organizations) => { + expect(organizations).toEqual([]); + done(); + }); + }); + + it('adds "myVault" when the policy does not apply and there are multiple organizations', (done) => { + policyAppliesToActiveUser$.next(false); + const orgs = [ + { name: "bobby's org", id: "1234-3323-23223" }, + { name: "alice's org", id: "2223-4343-99888" }, + ] as Organization[]; + + memberOrganizations$.next(orgs); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([ + "myVault", + "alice's org", + "bobby's org", + ]); + done(); + }); + }); + + it('does not add "myVault" the policy applies and there are multiple organizations', (done) => { + policyAppliesToActiveUser$.next(true); + const orgs = [ + { name: "bobby's org", id: "1234-3323-23223" }, + { name: "alice's org", id: "2223-3242-99888" }, + { name: "catherine's org", id: "77733-4343-99888" }, + ] as Organization[]; + + memberOrganizations$.next(orgs); + + service.organizations$.subscribe((organizations) => { + expect(organizations.map((o) => o.label)).toEqual([ + "alice's org", + "bobby's org", + "catherine's org", + ]); + done(); + }); + }); + }); + describe("icons", () => { it("sets family icon for family organizations", (done) => { const orgs = [ diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 6406e43446..66e264dd6d 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -13,6 +13,8 @@ import { import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { ProductType } from "@bitwarden/common/enums"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -88,6 +90,7 @@ export class VaultPopupListFiltersService { private i18nService: I18nService, private collectionService: CollectionService, private formBuilder: FormBuilder, + private policyService: PolicyService, ) { this.filterForm.controls.organization.valueChanges .pipe(takeUntilDestroyed()) @@ -167,44 +170,63 @@ export class VaultPopupListFiltersService { /** * Organization array structured to be directly passed to `ChipSelectComponent` */ - organizations$: Observable[]> = - this.organizationService.memberOrganizations$.pipe( - map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))), - map((orgs) => { - if (!orgs.length) { - return []; - } + organizations$: Observable[]> = combineLatest([ + this.organizationService.memberOrganizations$, + this.policyService.policyAppliesToActiveUser$(PolicyType.PersonalOwnership), + ]).pipe( + map(([orgs, personalOwnershipApplies]): [Organization[], boolean] => [ + orgs.sort(Utils.getSortFunction(this.i18nService, "name")), + personalOwnershipApplies, + ]), + map(([orgs, personalOwnershipApplies]) => { + // When there are no organizations return an empty array, + // resulting in the org filter being hidden + if (!orgs.length) { + return []; + } - return [ - // When the user is a member of an organization, make the "My Vault" option available - { - value: { id: MY_VAULT_ID } as Organization, - label: this.i18nService.t("myVault"), - icon: "bwi-user", - }, - ...orgs.map((org) => { - let icon = "bwi-business"; + // When there is only one organization and personal ownership policy applies, + // return an empty array, resulting in the org filter being hidden + if (orgs.length === 1 && personalOwnershipApplies) { + return []; + } - if (!org.enabled) { - // Show a warning icon if the organization is deactivated - icon = "bwi-exclamation-triangle tw-text-danger"; - } else if ( - org.planProductType === ProductType.Families || - org.planProductType === ProductType.Free - ) { - // Show a family icon if the organization is a family or free org - icon = "bwi-family"; - } + const myVaultOrg: ChipSelectOption[] = []; - return { - value: org, - label: org.name, - icon, - }; - }), - ]; - }), - ); + // Only add "My vault" if personal ownership policy does not apply + if (!personalOwnershipApplies) { + myVaultOrg.push({ + value: { id: MY_VAULT_ID } as Organization, + label: this.i18nService.t("myVault"), + icon: "bwi-user", + }); + } + + return [ + ...myVaultOrg, + ...orgs.map((org) => { + let icon = "bwi-business"; + + if (!org.enabled) { + // Show a warning icon if the organization is deactivated + icon = "bwi-exclamation-triangle tw-text-danger"; + } else if ( + org.planProductType === ProductType.Families || + org.planProductType === ProductType.Free + ) { + // Show a family icon if the organization is a family or free org + icon = "bwi-family"; + } + + return { + value: org, + label: org.name, + icon, + }; + }), + ]; + }), + ); /** * Folder array structured to be directly passed to `ChipSelectComponent` From f9faeeba4c9a928cf00936951484c6673786cf9d Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Tue, 11 Jun 2024 12:03:04 +0100 Subject: [PATCH 08/17] restrict deployment to USDEV and protect environment (#9571) * restrict deployment to USDEV and protect environment * remove converting env name to lower char --- .github/workflows/deploy-web.yml | 62 +++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 1ff6767141..52230a12bc 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -112,13 +112,48 @@ jobs: echo "azure-login-creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT echo "retrieve-secrets-keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT echo "environment-artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT - echo "environment-name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT + echo "environment-name=Web Vault - US DEV Cloud" >> $GITHUB_OUTPUT echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT ;; esac # Set the sync utility to use for deployment to the environment (az-sync or azcopy) echo "sync-utility=azcopy" >> $GITHUB_OUTPUT + - name: Environment Protection + env: + TAG: ${{ steps.project_tag.outputs.tag }} + run: | + BRANCH_OR_TAG_LOWER=$(echo ${{ inputs.branch-or-tag }} | awk '{print tolower($0)}') + + PROD_ENV_PATTERN='USPROD|EUPROD' + PROD_ALLOWED_TAGS_PATTERN='web-v[0-9]+\.[0-9]+\.[0-9]+' + + QA_ENV_PATTERN='USQA|EUQA' + QA_ALLOWED_TAGS_PATTERN='.*' + + DEV_ENV_PATTERN='USDEV' + DEV_ALLOWED_TAGS_PATTERN='.*' + + if [[ \ + ${{ inputs.environment }} =~ \.*($PROD_ENV_PATTERN)\.* && \ + ! "$BRANCH_OR_TAG_LOWER" =~ ^($PROD_ALLOWED_TAGS_PATTERN).* \ + ]] || [[ \ + ${{ inputs.environment }} =~ \.*($QA_ENV_PATTERN)\.* && \ + ! "$BRANCH_OR_TAG_LOWER" =~ ^($QA_ALLOWED_TAGS_PATTERN).* \ + ]] || [[ \ + =~ \.*($DEV_ENV_PATTERN)\.* && \ + ! "$BRANCH_OR_TAG_LOWER" =~ ^($DEV_ALLOWED_TAGS_PATTERN).* \ + ]]; then + echo "!Deployment blocked!" + echo "Attempting to deploy a tag that is not allowed in ${{ inputs.environment }} environment" + echo + echo "Environment: ${{ inputs.environment }} + echo "Tag: ${{ inputs.branch-or-tag }} + exit 1 + else + echo "${{ inputs.branch-or-tag }} is allowed to deployed on to ${{ inputs.environment }} environment" + fi + approval: name: Approval for Deployment to ${{ needs.setup.outputs.environment-name }} needs: setup @@ -206,6 +241,31 @@ jobs: echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT fi + - name: Ensure artifact is from main branch for USDEV environment + if: ${{ 'inputs.environment' == 'USDEV'}} + run: | + # If run-id was used + if [ "${{ inputs.build-web-run-id }}" ]; then + if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then + echo "Artifact is not from main branch" + exit 1 + fi + + # If artifact download failed + elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then + branch=$(gh api /repos/bitwarden/clients/actions/runs/${{ steps.trigger-build-web.outputs.workflow_id }}/artifacts --jq '.artifacts[0].workflow_run.head_branch') + if [ "$branch" != "main" ]; then + echo "Artifact is not from main branch" + exit 1 + fi + + else + if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then + echo "Artifact is not from main branch" + exit 1 + fi + fi + notify-start: name: Notify Slack with start message needs: From 9a35608fc3b151a35dcdc3d7d66561b95e130775 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Tue, 11 Jun 2024 15:31:37 +0100 Subject: [PATCH 09/17] Revert "restrict deployment to USDEV and protect environment (#9571)" (#9583) This reverts commit f9faeeba4c9a928cf00936951484c6673786cf9d. --- .github/workflows/deploy-web.yml | 62 +------------------------------- 1 file changed, 1 insertion(+), 61 deletions(-) diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 52230a12bc..1ff6767141 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -112,48 +112,13 @@ jobs: echo "azure-login-creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT echo "retrieve-secrets-keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT echo "environment-artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT - echo "environment-name=Web Vault - US DEV Cloud" >> $GITHUB_OUTPUT + echo "environment-name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT ;; esac # Set the sync utility to use for deployment to the environment (az-sync or azcopy) echo "sync-utility=azcopy" >> $GITHUB_OUTPUT - - name: Environment Protection - env: - TAG: ${{ steps.project_tag.outputs.tag }} - run: | - BRANCH_OR_TAG_LOWER=$(echo ${{ inputs.branch-or-tag }} | awk '{print tolower($0)}') - - PROD_ENV_PATTERN='USPROD|EUPROD' - PROD_ALLOWED_TAGS_PATTERN='web-v[0-9]+\.[0-9]+\.[0-9]+' - - QA_ENV_PATTERN='USQA|EUQA' - QA_ALLOWED_TAGS_PATTERN='.*' - - DEV_ENV_PATTERN='USDEV' - DEV_ALLOWED_TAGS_PATTERN='.*' - - if [[ \ - ${{ inputs.environment }} =~ \.*($PROD_ENV_PATTERN)\.* && \ - ! "$BRANCH_OR_TAG_LOWER" =~ ^($PROD_ALLOWED_TAGS_PATTERN).* \ - ]] || [[ \ - ${{ inputs.environment }} =~ \.*($QA_ENV_PATTERN)\.* && \ - ! "$BRANCH_OR_TAG_LOWER" =~ ^($QA_ALLOWED_TAGS_PATTERN).* \ - ]] || [[ \ - =~ \.*($DEV_ENV_PATTERN)\.* && \ - ! "$BRANCH_OR_TAG_LOWER" =~ ^($DEV_ALLOWED_TAGS_PATTERN).* \ - ]]; then - echo "!Deployment blocked!" - echo "Attempting to deploy a tag that is not allowed in ${{ inputs.environment }} environment" - echo - echo "Environment: ${{ inputs.environment }} - echo "Tag: ${{ inputs.branch-or-tag }} - exit 1 - else - echo "${{ inputs.branch-or-tag }} is allowed to deployed on to ${{ inputs.environment }} environment" - fi - approval: name: Approval for Deployment to ${{ needs.setup.outputs.environment-name }} needs: setup @@ -241,31 +206,6 @@ jobs: echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT fi - - name: Ensure artifact is from main branch for USDEV environment - if: ${{ 'inputs.environment' == 'USDEV'}} - run: | - # If run-id was used - if [ "${{ inputs.build-web-run-id }}" ]; then - if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then - echo "Artifact is not from main branch" - exit 1 - fi - - # If artifact download failed - elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then - branch=$(gh api /repos/bitwarden/clients/actions/runs/${{ steps.trigger-build-web.outputs.workflow_id }}/artifacts --jq '.artifacts[0].workflow_run.head_branch') - if [ "$branch" != "main" ]; then - echo "Artifact is not from main branch" - exit 1 - fi - - else - if [ "${{ steps.download-latest-artifacts.outputs.artifact-build-branch }}" != "main" ]; then - echo "Artifact is not from main branch" - exit 1 - fi - fi - notify-start: name: Notify Slack with start message needs: From f6702cd2d74c32dda2d60bc6df0e46b563978867 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:36:31 -0400 Subject: [PATCH 10/17] [AC-2595] [AC-2596] Empty clients placeholder and setup provider hint (#9505) * Added empty state to providers clients page * Added bitForm to Setup component and added billing email hint --- apps/web/src/locales/en/messages.json | 7 + .../providers/providers.module.ts | 2 + .../providers/setup/setup.component.html | 55 +++--- .../providers/setup/setup.component.ts | 173 ++++++++++-------- .../app/billing/providers/clients/index.ts | 1 + ...manage-client-organizations.component.html | 149 ++++++++------- .../providers/clients/no-clients.component.ts | 40 ++++ .../src/app/billing/providers/index.ts | 5 +- .../provider-payment-method.component.html | 2 +- .../manage-tax-information.component.html | 4 +- .../manage-tax-information.component.ts | 69 ++++--- 11 files changed, 304 insertions(+), 203 deletions(-) create mode 100644 bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 39423cbd90..9cbd44a963 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8330,5 +8330,12 @@ }, "viewSecret": { "message": "View secret" + }, + "noClients": { + "message": "There are no clients to list" + }, + "providerBillingEmailHint": { + "message": "This email address will receive all invoices pertaining to this provider", + "description": "A hint that shows up on the Provider setup page to inform the admin the billing email will receive the provider's invoices." } } diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts index baa3e5e1bb..00a3872584 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/providers.module.ts @@ -11,6 +11,7 @@ import { OssModule } from "@bitwarden/web-vault/app/oss.module"; import { CreateClientOrganizationComponent, + NoClientsComponent, ManageClientOrganizationNameComponent, ManageClientOrganizationsComponent, ManageClientOrganizationSubscriptionComponent, @@ -65,6 +66,7 @@ import { SetupComponent } from "./setup/setup.component"; SetupProviderComponent, UserAddEditComponent, CreateClientOrganizationComponent, + NoClientsComponent, ManageClientOrganizationsComponent, ManageClientOrganizationNameComponent, ManageClientOrganizationSubscriptionComponent, diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html index d1cf666874..0fd6725304 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.html @@ -1,40 +1,41 @@ -
+ + + {{ "loading" | i18n }} + +

{{ "setupProviderDesc" | i18n }}

- - +

{{ "generalInformation" | i18n }}

-
-
- - +
+
+ + {{ "providerName" | i18n }} + +
-
- - -
-
- +
+ + {{ "billingEmail" | i18n }} + + {{ + "providerBillingEmailHint" | i18n + }} +
- -
- -
+ +
diff --git a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts index 258088257d..845f2834b3 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/providers/setup/setup.component.ts @@ -1,38 +1,41 @@ -import { Component, OnInit, ViewChild } from "@angular/core"; +import { Component, OnDestroy, OnInit, ViewChild } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { firstValueFrom } from "rxjs"; -import { first } from "rxjs/operators"; +import { firstValueFrom, Subject, switchMap } from "rxjs"; +import { first, takeUntil } from "rxjs/operators"; +import { ManageTaxInformationComponent } from "@bitwarden/angular/billing/components"; import { ProviderApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/provider/provider-api.service.abstraction"; import { ProviderSetupRequest } from "@bitwarden/common/admin-console/models/request/provider/provider-setup.request"; +import { TaxInformation } from "@bitwarden/common/billing/models/domain"; import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request"; 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; import { ProviderKey } from "@bitwarden/common/types/key"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; -import { TaxInfoComponent } from "@bitwarden/web-vault/app/billing"; +import { ToastService } from "@bitwarden/components"; @Component({ selector: "provider-setup", templateUrl: "setup.component.html", }) -// eslint-disable-next-line rxjs-angular/prefer-takeuntil -export class SetupComponent implements OnInit { - @ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent; +export class SetupComponent implements OnInit, OnDestroy { + @ViewChild(ManageTaxInformationComponent) + manageTaxInformationComponent: ManageTaxInformationComponent; loading = true; - authed = false; - email: string; - formPromise: Promise; - providerId: string; token: string; - name: string; - billingEmail: string; + + protected formGroup = this.formBuilder.group({ + name: ["", Validators.required], + billingEmail: ["", [Validators.required, Validators.email]], + }); + + protected readonly TaxInformation = TaxInformation; protected showPaymentMethodWarningBanners$ = this.configService.getFeatureFlag$( FeatureFlag.ShowPaymentMethodWarningBanners, @@ -42,9 +45,10 @@ export class SetupComponent implements OnInit { FeatureFlag.EnableConsolidatedBilling, ); + private destroy$ = new Subject(); + constructor( private router: Router, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private route: ActivatedRoute, private cryptoService: CryptoService, @@ -52,61 +56,81 @@ export class SetupComponent implements OnInit { private validationService: ValidationService, private configService: ConfigService, private providerApiService: ProviderApiServiceAbstraction, + private formBuilder: FormBuilder, + private toastService: ToastService, ) {} ngOnInit() { document.body.classList.remove("layout_frontend"); - // eslint-disable-next-line rxjs-angular/prefer-takeuntil, rxjs/no-async-subscribe - this.route.queryParams.pipe(first()).subscribe(async (qParams) => { - const error = qParams.providerId == null || qParams.email == null || qParams.token == null; - if (error) { - this.platformUtilsService.showToast( - "error", - null, - this.i18nService.t("emergencyInviteAcceptFailed"), - { timeout: 10000 }, - ); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); + this.route.queryParams + .pipe( + first(), + switchMap(async (queryParams) => { + const error = + queryParams.providerId == null || + queryParams.email == null || + queryParams.token == null; + + if (error) { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("emergencyInviteAcceptFailed"), + timeout: 10000, + }); + + return await this.router.navigate(["/"]); + } + + this.providerId = queryParams.providerId; + this.token = queryParams.token; + + try { + const provider = await this.providerApiService.getProvider(this.providerId); + + if (provider.name != null) { + /* + This is currently always going to result in a redirect to the Vault because the `provider-permissions.guard` + checks for the existence of the Provider in state. However, when accessing the Setup page via the email link, + this `navigate` invocation will be hit before the sync can complete, thus resulting in a null Provider. If we want + to resolve it, we'd either need to use the ProviderApiService in the provider-permissions.guard (added expense) + or somehow check that the previous route was /setup. + */ + return await this.router.navigate(["/providers", provider.id], { + replaceUrl: true, + }); + } + this.loading = false; + } catch (error) { + this.validationService.showError(error); + return await this.router.navigate(["/"]); + } + }), + takeUntil(this.destroy$), + ) + .subscribe(); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + submit = async () => { + try { + this.formGroup.markAllAsTouched(); + const taxInformationValid = this.manageTaxInformationComponent.touch(); + if (this.formGroup.invalid || !taxInformationValid) { return; } - this.providerId = qParams.providerId; - this.token = qParams.token; - - // Check if provider exists, redirect if it does - try { - const provider = await this.providerApiService.getProvider(this.providerId); - if (provider.name != null) { - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/providers", provider.id], { replaceUrl: true }); - } - } catch (e) { - this.validationService.showError(e); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/"]); - } - }); - } - - async submit() { - this.formPromise = this.doSubmit(); - await this.formPromise; - this.formPromise = null; - } - - async doSubmit() { - try { const providerKey = await this.cryptoService.makeOrgKey(); const key = providerKey[0].encryptedString; const request = new ProviderSetupRequest(); - request.name = this.name; - request.billingEmail = this.billingEmail; + request.name = this.formGroup.value.name; + request.billingEmail = this.formGroup.value.billingEmail; request.token = this.token; request.key = key; @@ -114,27 +138,32 @@ export class SetupComponent implements OnInit { if (enableConsolidatedBilling) { request.taxInfo = new ExpandedTaxInfoUpdateRequest(); - const taxInfoView = this.taxInfoComponent.taxInfo; - request.taxInfo.country = taxInfoView.country; - request.taxInfo.postalCode = taxInfoView.postalCode; - if (taxInfoView.includeTaxId) { - request.taxInfo.taxId = taxInfoView.taxId; - request.taxInfo.line1 = taxInfoView.line1; - request.taxInfo.line2 = taxInfoView.line2; - request.taxInfo.city = taxInfoView.city; - request.taxInfo.state = taxInfoView.state; + const taxInformation = this.manageTaxInformationComponent.getTaxInformation(); + + request.taxInfo.country = taxInformation.country; + request.taxInfo.postalCode = taxInformation.postalCode; + if (taxInformation.includeTaxId) { + request.taxInfo.taxId = taxInformation.taxId; + request.taxInfo.line1 = taxInformation.line1; + request.taxInfo.line2 = taxInformation.line2; + request.taxInfo.city = taxInformation.city; + request.taxInfo.state = taxInformation.state; } } const provider = await this.providerApiService.postProviderSetup(this.providerId, request); - this.platformUtilsService.showToast("success", null, this.i18nService.t("providerSetup")); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("providerSetup"), + }); + await this.syncService.fullSync(true); - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.router.navigate(["/providers", provider.id]); + await this.router.navigate(["/providers", provider.id]); } catch (e) { this.validationService.showError(e); } - } + }; } diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts index 1968302766..fa1bc137fc 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/index.ts @@ -2,3 +2,4 @@ export * from "./create-client-organization.component"; export * from "./manage-client-organizations.component"; export * from "./manage-client-organization-name.component"; export * from "./manage-client-organization-subscription.component"; +export * from "./no-clients.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html index d2f8ab7a85..9a84b92837 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/manage-client-organizations.component.html @@ -21,80 +21,77 @@ {{ "loading" | i18n }} - -

{{ "noClientsInList" | i18n }}

- - - - - {{ "client" | i18n }} - {{ "assigned" | i18n }} - {{ "used" | i18n }} - {{ "remaining" | i18n }} - {{ "billingPlan" | i18n }} - - - - - - - - - - - - - {{ client.seats }} - - - {{ client.userCount }} - - - {{ client.seats - client.userCount }} - - - {{ client.plan }} - - - - - - - - - - - - - + + + + + {{ "client" | i18n }} + {{ "assigned" | i18n }} + {{ "used" | i18n }} + {{ "remaining" | i18n }} + {{ "billingPlan" | i18n }} + + + + + + + + + + + + + {{ client.seats }} + + + {{ client.userCount }} + + + {{ client.seats - client.userCount }} + + + {{ client.plan }} + + + + + + + + + + + + +
+ +
diff --git a/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts new file mode 100644 index 0000000000..c785ee8bd0 --- /dev/null +++ b/bitwarden_license/bit-web/src/app/billing/providers/clients/no-clients.component.ts @@ -0,0 +1,40 @@ +import { Component, EventEmitter, Output } from "@angular/core"; + +import { svgIcon } from "@bitwarden/components"; + +const gearIcon = svgIcon` + + + + + + + + + + + + + + + + +`; + +@Component({ + selector: "app-no-clients", + template: `
+ +

{{ "noClients" | i18n }}

+ + + {{ "addNewOrganization" | i18n }} + +
`, +}) +export class NoClientsComponent { + icon = gearIcon; + @Output() addNewOrganizationClicked = new EventEmitter(); + + addNewOrganization = () => this.addNewOrganizationClicked.emit(); +} diff --git a/bitwarden_license/bit-web/src/app/billing/providers/index.ts b/bitwarden_license/bit-web/src/app/billing/providers/index.ts index 9b899f1741..71af56f7b0 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/index.ts +++ b/bitwarden_license/bit-web/src/app/billing/providers/index.ts @@ -1,7 +1,4 @@ -export * from "./clients/create-client-organization.component"; -export * from "./clients/manage-client-organization-name.component"; -export * from "./clients/manage-client-organization-subscription.component"; -export * from "./clients/manage-client-organizations.component"; +export * from "./clients"; export * from "./guards/has-consolidated-billing.guard"; export * from "./payment-method/provider-select-payment-method-dialog.component"; export * from "./payment-method/provider-payment-method.component"; diff --git a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html index 1a1e0529fc..e2457294eb 100644 --- a/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html +++ b/bitwarden_license/bit-web/src/app/billing/providers/payment-method/provider-payment-method.component.html @@ -44,7 +44,7 @@

{{ "taxInformationDesc" | i18n }}

diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html index f9cfa8e0fa..0b041bd4c0 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.html @@ -1,7 +1,7 @@
- + {{ "country" | i18n }}
- + {{ "zipPostalCode" | i18n }} diff --git a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts index 58342548ca..f048cf0d36 100644 --- a/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts +++ b/libs/angular/src/billing/components/manage-tax-information/manage-tax-information.component.ts @@ -1,5 +1,6 @@ -import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; +import { Subject, takeUntil } from "rxjs"; import { TaxInformation } from "@bitwarden/common/billing/models/domain"; @@ -13,8 +14,8 @@ type Country = { selector: "app-manage-tax-information", templateUrl: "./manage-tax-information.component.html", }) -export class ManageTaxInformationComponent implements OnInit { - @Input({ required: true }) taxInformation: TaxInformation; +export class ManageTaxInformationComponent implements OnInit, OnDestroy { + @Input() startWith: TaxInformation; @Input() onSubmit?: (taxInformation: TaxInformation) => Promise; @Output() taxInformationUpdated = new EventEmitter(); @@ -29,35 +30,61 @@ export class ManageTaxInformationComponent implements OnInit { state: "", }); + private destroy$ = new Subject(); + + private taxInformation: TaxInformation; + constructor(private formBuilder: FormBuilder) {} - submit = async () => { - await this.onSubmit({ - country: this.formGroup.value.country, - postalCode: this.formGroup.value.postalCode, - taxId: this.formGroup.value.taxId, - line1: this.formGroup.value.line1, - line2: this.formGroup.value.line2, - city: this.formGroup.value.city, - state: this.formGroup.value.state, - }); + getTaxInformation = (): TaxInformation & { includeTaxId: boolean } => ({ + ...this.taxInformation, + includeTaxId: this.formGroup.value.includeTaxId, + }); + submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } + await this.onSubmit(this.taxInformation); this.taxInformationUpdated.emit(); }; + touch = (): boolean => { + this.formGroup.markAllAsTouched(); + return this.formGroup.valid; + }; + async ngOnInit() { - if (this.taxInformation) { + if (this.startWith) { this.formGroup.patchValue({ - ...this.taxInformation, + ...this.startWith, includeTaxId: - this.countrySupportsTax(this.taxInformation.country) && - (!!this.taxInformation.taxId || - !!this.taxInformation.line1 || - !!this.taxInformation.line2 || - !!this.taxInformation.city || - !!this.taxInformation.state), + this.countrySupportsTax(this.startWith.country) && + (!!this.startWith.taxId || + !!this.startWith.line1 || + !!this.startWith.line2 || + !!this.startWith.city || + !!this.startWith.state), }); } + + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((values) => { + this.taxInformation = { + country: values.country, + postalCode: values.postalCode, + taxId: values.taxId, + line1: values.line1, + line2: values.line2, + city: values.city, + state: values.state, + }; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } protected countrySupportsTax(countryCode: string) { From e6803e05ee0d7bae7d5f580dd40ddc02162dbc65 Mon Sep 17 00:00:00 2001 From: Tom <144813356+ttalty@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:36:31 -0400 Subject: [PATCH 11/17] [PM-8593] CLI - Logout needs to reset active account (#9503) * On logging out the account service active account needs set to null * Auth service logout back to old spot and account switch after cleaning the state --- apps/cli/src/service-container.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/service-container.ts b/apps/cli/src/service-container.ts index ff4eb52b84..8749eeb982 100644 --- a/apps/cli/src/service-container.ts +++ b/apps/cli/src/service-container.ts @@ -753,6 +753,7 @@ export class ServiceContainer { await this.stateService.clean(); await this.accountService.clean(userId); + await this.accountService.switchAccount(null); process.env.BW_SESSION = null; } From 832abcd955d7de57dcf549d75ed40f0de6103e07 Mon Sep 17 00:00:00 2001 From: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Date: Tue, 11 Jun 2024 23:17:55 +0530 Subject: [PATCH 12/17] [PM-2057] update two factor email dialog (#9547) * migrating two factor email component * two factor email component migration * two factor email component migration * two factor email component migration * two factor email component migration --- .../src/app/auth/settings/two-factor-email.component.html | 4 ++-- .../web/src/app/auth/settings/two-factor-email.component.ts | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.html b/apps/web/src/app/auth/settings/two-factor-email.component.html index cf1dba9884..d15bde1d93 100644 --- a/apps/web/src/app/auth/settings/two-factor-email.component.html +++ b/apps/web/src/app/auth/settings/two-factor-email.component.html @@ -26,12 +26,12 @@ appInputVerbatim="false" /> -
+
- {{ "verificationCodeEmailSent" | i18n: sentEmail }} + {{ "emailSent" | i18n }}
diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.ts b/apps/web/src/app/auth/settings/two-factor-email.component.ts index 8a5c029223..b0b7c0a64f 100644 --- a/apps/web/src/app/auth/settings/two-factor-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor-email.component.ts @@ -31,7 +31,7 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { emailPromise: Promise; override componentName = "app-two-factor-email"; formGroup = this.formBuilder.group({ - token: [null], + token: ["", [Validators.required]], email: ["", [Validators.email, Validators.required]], }); @@ -79,6 +79,10 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent { } submit = async () => { + this.formGroup.markAllAsTouched(); + if (this.formGroup.invalid) { + return; + } if (this.enabled) { await this.disableEmail(); this.onChangeStatus.emit(false); From 19d863c9efe24764d335495d43f5e9b0890a8def Mon Sep 17 00:00:00 2001 From: vinith-kovan <156108204+vinith-kovan@users.noreply.github.com> Date: Tue, 11 Jun 2024 23:25:58 +0530 Subject: [PATCH 13/17] [PM-4956] two factor component migration (#9204) * two factor component migration * two factor component migration * two factor component migration * two factor component migration * two factor component migration --- .../src/app/auth/two-factor.component.html | 285 ++++++++---------- apps/web/src/app/auth/two-factor.component.ts | 26 +- apps/web/src/app/oss-routing.module.ts | 28 +- apps/web/src/locales/en/messages.json | 3 + 4 files changed, 172 insertions(+), 170 deletions(-) diff --git a/apps/web/src/app/auth/two-factor.component.html b/apps/web/src/app/auth/two-factor.component.html index df86db99ee..7c63553e43 100644 --- a/apps/web/src/app/auth/two-factor.component.html +++ b/apps/web/src/app/auth/two-factor.component.html @@ -1,173 +1,124 @@ - -
-
+
+ -

{{ title }}

-
-
- + {{ "enterVerificationCodeApp" | i18n }} +

+

+ {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} +

+ + {{ "verificationCode" | i18n }} + + + -

- {{ "enterVerificationCodeApp" | i18n }} -

-

- {{ "enterVerificationCodeEmail" | i18n: twoFactorEmail }} -

-
-
- -

{{ "insertYubiKey" | i18n }}

- - - - - -
- - -
-
- -
- -
-
- - - -

- {{ "duoRequiredByOrgForAccount" | i18n }} -

-

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

-
- - -
- -
-
-
- -
- - -
- -

{{ "noTwoStepProviders" | i18n }}

-

{{ "noTwoStepProviders2" | i18n }}

-
-
-
- -
- -
- - - - {{ "cancel" | i18n }} - -
- -
+ {{ "sendVerificationCodeEmailAgain" | i18n }} + + + + +

{{ "insertYubiKey" | i18n }}

+ + + + + + + {{ "verificationCode" | i18n }} + + +
+ +
+
+
+ + + +

+ {{ "duoRequiredByOrgForAccount" | i18n }} +

+

{{ "launchDuoAndFollowStepsToFinishLoggingIn" | i18n }}

+
+ + +
+ +
+
+
+ + {{ "rememberMe" | i18n }} + + + +

{{ "noTwoStepProviders" | i18n }}

+

{{ "noTwoStepProviders2" | i18n }}

+
+
+
+ +
+ +
+ + + + {{ "cancel" | i18n }} + +
+
diff --git a/apps/web/src/app/auth/two-factor.component.ts b/apps/web/src/app/auth/two-factor.component.ts index c91ae4674d..741037cc30 100644 --- a/apps/web/src/app/auth/two-factor.component.ts +++ b/apps/web/src/app/auth/two-factor.component.ts @@ -1,6 +1,7 @@ import { Component, Inject, OnDestroy, ViewChild, ViewContainerRef } from "@angular/core"; +import { FormBuilder, Validators } from "@angular/forms"; import { ActivatedRoute, Router } from "@angular/router"; -import { lastValueFrom } from "rxjs"; +import { Subject, takeUntil, lastValueFrom } from "rxjs"; import { TwoFactorComponent as BaseTwoFactorComponent } from "@bitwarden/angular/auth/components/two-factor.component"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; @@ -38,7 +39,17 @@ import { export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDestroy { @ViewChild("twoFactorOptions", { read: ViewContainerRef, static: true }) twoFactorOptionsModal: ViewContainerRef; - + formGroup = this.formBuilder.group({ + token: [ + "", + { + validators: [Validators.required], + updateOn: "submit", + }, + ], + remember: [false], + }); + private destroy$ = new Subject(); constructor( loginStrategyService: LoginStrategyServiceAbstraction, router: Router, @@ -58,6 +69,7 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest configService: ConfigService, masterPasswordService: InternalMasterPasswordServiceAbstraction, accountService: AccountService, + private formBuilder: FormBuilder, @Inject(WINDOW) protected win: Window, ) { super( @@ -82,6 +94,16 @@ export class TwoFactorComponent extends BaseTwoFactorComponent implements OnDest ); this.onSuccessfulLoginNavigate = this.goAfterLogIn; } + async ngOnInit() { + await super.ngOnInit(); + this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.token = value.token; + this.remember = value.remember; + }); + } + submitForm = async () => { + await this.submit(); + }; async anotherMethod() { const dialogRef = TwoFactorOptionsComponent.open(this.dialogService); diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 1115a60bf9..44cfc10873 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -82,7 +82,6 @@ const routes: Routes = [ component: LoginViaAuthRequestComponent, data: { titleId: "adminApprovalRequested" } satisfies DataProperties, }, - { path: "2fa", component: TwoFactorComponent, canActivate: [UnauthGuard] }, { path: "login-initiated", component: LoginDecryptionOptionsComponent, @@ -189,6 +188,33 @@ const routes: Routes = [ path: "", component: AnonLayoutWrapperComponent, children: [ + { + path: "2fa", + component: TwoFactorComponent, + canActivate: [unauthGuardFn()], + data: { + pageTitle: "verifyIdentity", + }, + }, + { + path: "recover-2fa", + canActivate: [unauthGuardFn()], + children: [ + { + path: "", + component: RecoverTwoFactorComponent, + }, + { + path: "", + component: EnvironmentSelectorComponent, + outlet: "environment-selector", + }, + ], + data: { + pageTitle: "recoverAccountTwoStep", + titleId: "recoverAccountTwoStep", + } satisfies DataProperties & AnonLayoutWrapperData, + }, { path: "accept-emergency", canActivate: [deepLinkGuard()], diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 9cbd44a963..964cfe7536 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -722,6 +722,9 @@ "logIn": { "message": "Log in" }, + "verifyIdentity": { + "message": "Verify your Identity" + }, "logInInitiated": { "message": "Log in initiated" }, From 9b0250d4fd344efda56ef33e9bc6147cfb7efe7f Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:06:02 -0700 Subject: [PATCH 14/17] Check undefined data properties before i18n (#9590) * remove duplicate route * check for undefined before translation --- apps/web/src/app/oss-routing.module.ts | 19 ------------------ .../anon-layout-wrapper.component.ts | 20 +++++++++++++++---- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 44cfc10873..209a291a71 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -238,25 +238,6 @@ const routes: Routes = [ }, ], }, - { - path: "recover-2fa", - canActivate: [unauthGuardFn()], - children: [ - { - path: "", - component: RecoverTwoFactorComponent, - }, - { - path: "", - component: EnvironmentSelectorComponent, - outlet: "environment-selector", - }, - ], - data: { - pageTitle: "recoverAccountTwoStep", - titleId: "recoverAccountTwoStep", - } satisfies DataProperties & AnonLayoutWrapperData, - }, { path: "remove-password", component: RemovePasswordComponent, diff --git a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts index 5efd2cf9ab..e4472c368f 100644 --- a/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/auth/src/angular/anon-layout/anon-layout-wrapper.component.ts @@ -27,9 +27,21 @@ export class AnonLayoutWrapperComponent { private route: ActivatedRoute, private i18nService: I18nService, ) { - this.pageTitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageTitle"]); - this.pageSubtitle = this.i18nService.t(this.route.snapshot.firstChild.data["pageSubtitle"]); - this.pageIcon = this.route.snapshot.firstChild.data["pageIcon"]; - this.showReadonlyHostname = this.route.snapshot.firstChild.data["showReadonlyHostname"]; + const routeData = this.route.snapshot.firstChild?.data; + + if (!routeData) { + return; + } + + if (routeData["pageTitle"] !== undefined) { + this.pageTitle = this.i18nService.t(routeData["pageTitle"]); + } + + if (routeData["pageSubtitle"] !== undefined) { + this.pageSubtitle = this.i18nService.t(routeData["pageSubtitle"]); + } + + this.pageIcon = routeData["pageIcon"]; + this.showReadonlyHostname = routeData["showReadonlyHostname"]; } } From 9e6fabaa39e28871db08c11817100b4246bc5568 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Wed, 12 Jun 2024 05:58:41 +1000 Subject: [PATCH 15/17] Use --organizationid flag for device-approval commands (#9576) --- .../device-approval.program.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts index 0b0f3bb0f9..408a5b8d81 100644 --- a/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts +++ b/bitwarden_license/bit-cli/src/admin-console/device-approval/device-approval.program.ts @@ -11,6 +11,10 @@ import { DenyAllCommand } from "./deny-all.command"; import { DenyCommand } from "./deny.command"; import { ListCommand } from "./list.command"; +type Options = { + organizationid: string; +}; + export class DeviceApprovalProgram extends BaseProgram { constructor(protected serviceContainer: ServiceContainer) { super(serviceContainer); @@ -33,8 +37,8 @@ export class DeviceApprovalProgram extends BaseProgram { private listCommand(): Command { return new Command("list") .description("List all pending requests for an organization") - .argument("") - .action(async (organizationId: string) => { + .requiredOption("--organizationid ", "The organization id (required)") + .action(async (options: Options) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); @@ -42,17 +46,18 @@ export class DeviceApprovalProgram extends BaseProgram { this.serviceContainer.organizationAuthRequestService, this.serviceContainer.organizationService, ); - const response = await cmd.run(organizationId); + + const response = await cmd.run(options.organizationid); this.processResponse(response); }); } private approveCommand(): Command { return new Command("approve") - .argument("", "The id of the organization") .argument("", "The id of the request to approve") + .requiredOption("--organizationid ", "The organization id (required)") .description("Approve a pending request") - .action(async (organizationId: string, id: string) => { + .action(async (id: string, options: Options) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); @@ -60,7 +65,7 @@ export class DeviceApprovalProgram extends BaseProgram { this.serviceContainer.organizationService, this.serviceContainer.organizationAuthRequestService, ); - const response = await cmd.run(organizationId, id); + const response = await cmd.run(options.organizationid, id); this.processResponse(response); }); } @@ -68,8 +73,8 @@ export class DeviceApprovalProgram extends BaseProgram { private approveAllCommand(): Command { return new Command("approve-all") .description("Approve all pending requests for an organization") - .argument("") - .action(async (organizationId: string) => { + .requiredOption("--organizationid ", "The organization id (required)") + .action(async (options: Options) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); @@ -77,17 +82,17 @@ export class DeviceApprovalProgram extends BaseProgram { this.serviceContainer.organizationAuthRequestService, this.serviceContainer.organizationService, ); - const response = await cmd.run(organizationId); + const response = await cmd.run(options.organizationid); this.processResponse(response); }); } private denyCommand(): Command { return new Command("deny") - .argument("", "The id of the organization") .argument("", "The id of the request to deny") + .requiredOption("--organizationid ", "The organization id (required)") .description("Deny a pending request") - .action(async (organizationId: string, id: string) => { + .action(async (id: string, options: Options) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); @@ -95,7 +100,7 @@ export class DeviceApprovalProgram extends BaseProgram { this.serviceContainer.organizationService, this.serviceContainer.organizationAuthRequestService, ); - const response = await cmd.run(organizationId, id); + const response = await cmd.run(options.organizationid, id); this.processResponse(response); }); } @@ -103,8 +108,8 @@ export class DeviceApprovalProgram extends BaseProgram { private denyAllCommand(): Command { return new Command("deny-all") .description("Deny all pending requests for an organization") - .argument("") - .action(async (organizationId: string) => { + .requiredOption("--organizationid ", "The organization id (required)") + .action(async (options: Options) => { await this.exitIfFeatureFlagDisabled(FeatureFlag.BulkDeviceApproval); await this.exitIfLocked(); @@ -112,7 +117,7 @@ export class DeviceApprovalProgram extends BaseProgram { this.serviceContainer.organizationService, this.serviceContainer.organizationAuthRequestService, ); - const response = await cmd.run(organizationId); + const response = await cmd.run(options.organizationid); this.processResponse(response); }); } From fe82dbe2b96e90768eb38d94f830ea523a002556 Mon Sep 17 00:00:00 2001 From: Cesar Gonzalez Date: Tue, 11 Jun 2024 15:00:05 -0500 Subject: [PATCH 16/17] [PM-8510] Implement collect page details observable (#9452) * Working through a POC of a collectPageDetails observable * Implementing collect page details observable * [PM-8510] Implement collectPageDetails observable * [PM-8510] Adding documentation to newly created collectPageDetailsFromTab method * [PM-8510] Removing unnecessary file * [PM-8510] Implementing Jest tests for the collectPageDetailsFromTab$ method * [PM-8510] Implementing Jest tests for the collectPageDetailsFromTab$ method * [PM-8510] Implementing Jest tests for the collectPageDetailsFromTab$ method * [PM-8510] Implementing Jest tests for the collectPageDetailsFromTab$ method * [PM-8510] Removing unnecessary property * [PM-8510] Adding subscription reference to current tab component * [PM-8510] Fixing jest tests --- .../autofill/commands/autofill-tab-command.ts | 71 --------------- .../autofill/enums/autofill-message.enums.ts | 14 +++ .../services/abstractions/autofill.service.ts | 17 ++++ .../services/autofill.service.spec.ts | 89 ++++++++++++++++++- .../src/autofill/services/autofill.service.ts | 44 ++++++++- .../browser/src/background/main.background.ts | 1 + .../src/popup/services/services.module.ts | 1 + .../components/vault/current-tab.component.ts | 38 +++----- .../popup/components/vault/view.component.ts | 24 ++--- 9 files changed, 180 insertions(+), 119 deletions(-) delete mode 100644 apps/browser/src/autofill/commands/autofill-tab-command.ts create mode 100644 apps/browser/src/autofill/enums/autofill-message.enums.ts diff --git a/apps/browser/src/autofill/commands/autofill-tab-command.ts b/apps/browser/src/autofill/commands/autofill-tab-command.ts deleted file mode 100644 index 16ce40ff3d..0000000000 --- a/apps/browser/src/autofill/commands/autofill-tab-command.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; - -import AutofillPageDetails from "../models/autofill-page-details"; -import { AutofillService } from "../services/abstractions/autofill.service"; - -export class AutofillTabCommand { - constructor(private autofillService: AutofillService) {} - - async doAutofillTabCommand(tab: chrome.tabs.Tab) { - if (!tab.id) { - throw new Error("Tab does not have an id, cannot complete autofill."); - } - - const details = await this.collectPageDetails(tab.id); - await this.autofillService.doAutoFillOnTab( - [ - { - frameId: 0, - tab: tab, - details: details, - }, - ], - tab, - true, - ); - } - - async doAutofillTabWithCipherCommand(tab: chrome.tabs.Tab, cipher: CipherView) { - if (!tab.id) { - throw new Error("Tab does not have an id, cannot complete autofill."); - } - - const details = await this.collectPageDetails(tab.id); - await this.autofillService.doAutoFill({ - tab: tab, - cipher: cipher, - pageDetails: [ - { - frameId: 0, - tab: tab, - details: details, - }, - ], - skipLastUsed: false, - skipUsernameOnlyFill: false, - onlyEmptyFields: false, - onlyVisibleFields: false, - fillNewPassword: true, - allowTotpAutofill: true, - }); - } - - private async collectPageDetails(tabId: number): Promise { - return new Promise((resolve, reject) => { - chrome.tabs.sendMessage( - tabId, - { - command: "collectPageDetailsImmediately", - }, - (response: AutofillPageDetails) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); - return; - } - - resolve(response); - }, - ); - }); - } -} diff --git a/apps/browser/src/autofill/enums/autofill-message.enums.ts b/apps/browser/src/autofill/enums/autofill-message.enums.ts new file mode 100644 index 0000000000..4fdae2d914 --- /dev/null +++ b/apps/browser/src/autofill/enums/autofill-message.enums.ts @@ -0,0 +1,14 @@ +export const AutofillMessageCommand = { + collectPageDetails: "collectPageDetails", + collectPageDetailsResponse: "collectPageDetailsResponse", +} as const; + +export type AutofillMessageCommandType = + (typeof AutofillMessageCommand)[keyof typeof AutofillMessageCommand]; + +export const AutofillMessageSender = { + collectPageDetailsFromTabObservable: "collectPageDetailsFromTabObservable", +} as const; + +export type AutofillMessageSenderType = + (typeof AutofillMessageSender)[keyof typeof AutofillMessageSender]; diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 54a91a5176..9bdb85a3f2 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -1,7 +1,11 @@ +import { Observable } from "rxjs"; + import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service"; +import { CommandDefinition } from "@bitwarden/common/platform/messaging"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { AutofillMessageCommand } from "../../enums/autofill-message.enums"; import AutofillField from "../../models/autofill-field"; import AutofillForm from "../../models/autofill-form"; import AutofillPageDetails from "../../models/autofill-page-details"; @@ -44,7 +48,20 @@ export interface GenerateFillScriptOptions { defaultUriMatch: UriMatchStrategySetting; } +export type CollectPageDetailsResponseMessage = { + tab: chrome.tabs.Tab; + details: AutofillPageDetails; + sender?: string; + webExtSender: chrome.runtime.MessageSender; +}; + +export const COLLECT_PAGE_DETAILS_RESPONSE_COMMAND = + new CommandDefinition( + AutofillMessageCommand.collectPageDetailsResponse, + ); + export abstract class AutofillService { + collectPageDetailsFromTab$: (tab: chrome.tabs.Tab) => Observable; loadAutofillScriptsOnInstall: () => Promise; reloadAutofillScripts: () => Promise; injectAutofillScripts: ( diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index 23f690544d..2427c754ae 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -1,5 +1,5 @@ import { mock, mockReset, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, of } from "rxjs"; +import { BehaviorSubject, of, Subject } from "rxjs"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; @@ -16,12 +16,14 @@ import { EventType } from "@bitwarden/common/enums"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { FakeStateProvider, FakeAccountService, mockAccountServiceWith, + subscribeTo, } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { FieldType, LinkedIdType, LoginLinkedId, CipherType } from "@bitwarden/common/vault/enums"; @@ -37,6 +39,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; +import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; import { AutofillPort } from "../enums/autofill-port.enums"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -52,6 +55,7 @@ import { flushPromises, triggerTestFailure } from "../spec/testing-utils"; import { AutoFillOptions, + CollectPageDetailsResponseMessage, GenerateFillScriptOptions, PageDetail, } from "./abstractions/autofill.service"; @@ -82,6 +86,7 @@ describe("AutofillService", () => { const platformUtilsService = mock(); let activeAccountStatusMock$: BehaviorSubject; let authService: MockProxy; + let messageListener: MockProxy; beforeEach(() => { scriptInjectorService = new BrowserScriptInjectorService(platformUtilsService, logService); @@ -91,6 +96,7 @@ describe("AutofillService", () => { activeAccountStatusMock$ = new BehaviorSubject(AuthenticationStatus.Unlocked); authService = mock(); authService.activeAccountStatus$ = activeAccountStatusMock$; + messageListener = mock(); autofillService = new AutofillService( cipherService, autofillSettingsService, @@ -103,10 +109,11 @@ describe("AutofillService", () => { scriptInjectorService, accountService, authService, + messageListener, ); - domainSettingsService = new DefaultDomainSettingsService(fakeStateProvider); domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + jest.spyOn(BrowserApi, "tabSendMessage"); }); afterEach(() => { @@ -114,6 +121,84 @@ describe("AutofillService", () => { mockReset(cipherService); }); + describe("collectPageDetailsFromTab$", () => { + const tab = mock({ id: 1 }); + const messages = new Subject(); + + function mockCollectPageDetailsResponseMessage( + tab: chrome.tabs.Tab, + webExtSender: chrome.runtime.MessageSender = mock(), + sender: string = AutofillMessageSender.collectPageDetailsFromTabObservable, + ): CollectPageDetailsResponseMessage { + return mock({ + tab, + webExtSender, + sender, + }); + } + + beforeEach(() => { + messageListener.messages$.mockReturnValue(messages.asObservable()); + }); + + it("sends a `collectPageDetails` message to the passed tab", () => { + autofillService.collectPageDetailsFromTab$(tab); + + expect(BrowserApi.tabSendMessage).toHaveBeenCalledWith(tab, { + command: AutofillMessageCommand.collectPageDetails, + sender: AutofillMessageSender.collectPageDetailsFromTabObservable, + tab, + }); + }); + + it("builds an array of page details from received `collectPageDetailsResponse` messages", async () => { + const topLevelSender = mock({ tab, frameId: 0 }); + const subFrameSender = mock({ tab, frameId: 1 }); + + const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab)); + const pausePromise = tracker.pauseUntilReceived(2); + + messages.next(mockCollectPageDetailsResponseMessage(tab, topLevelSender)); + messages.next(mockCollectPageDetailsResponseMessage(tab, subFrameSender)); + + await pausePromise; + + expect(tracker.emissions[1].length).toBe(2); + }); + + it("ignores messages from a different tab", async () => { + const otherTab = mock({ id: 2 }); + + const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab)); + const pausePromise = tracker.pauseUntilReceived(1); + + messages.next(mockCollectPageDetailsResponseMessage(tab)); + messages.next(mockCollectPageDetailsResponseMessage(otherTab)); + + await pausePromise; + + expect(tracker.emissions[1]).toBeUndefined(); + }); + + it("ignores messages from a different sender", async () => { + const tracker = subscribeTo(autofillService.collectPageDetailsFromTab$(tab)); + const pausePromise = tracker.pauseUntilReceived(1); + + messages.next(mockCollectPageDetailsResponseMessage(tab)); + messages.next( + mockCollectPageDetailsResponseMessage( + tab, + mock(), + "some-other-sender", + ), + ); + + await pausePromise; + + expect(tracker.emissions[1]).toBeUndefined(); + }); + }); + describe("loadAutofillScriptsOnInstall", () => { let tab1: chrome.tabs.Tab; let tab2: chrome.tabs.Tab; diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 9ec2052381..ef051e5912 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom, startWith } from "rxjs"; +import { filter, firstValueFrom, Observable, scan, startWith } from "rxjs"; import { pairwise } from "rxjs/operators"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -17,6 +17,7 @@ import { UriMatchStrategy, } from "@bitwarden/common/models/domain/domain-service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessageListener } from "@bitwarden/common/platform/messaging"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; import { FieldType, CipherType } from "@bitwarden/common/vault/enums"; @@ -27,6 +28,7 @@ import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; +import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; import { AutofillPort } from "../enums/autofill-port.enums"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -35,6 +37,7 @@ import AutofillScript from "../models/autofill-script"; import { AutoFillOptions, AutofillService as AutofillServiceInterface, + COLLECT_PAGE_DETAILS_RESPONSE_COMMAND, FormData, GenerateFillScriptOptions, PageDetail, @@ -64,8 +67,47 @@ export default class AutofillService implements AutofillServiceInterface { private scriptInjectorService: ScriptInjectorService, private accountService: AccountService, private authService: AuthService, + private messageListener: MessageListener, ) {} + /** + * Collects page details from the specific tab. This method returns an observable that can + * be subscribed to in order to build the results from all collectPageDetailsResponse + * messages from the given tab. + * + * @param tab The tab to collect page details from + */ + collectPageDetailsFromTab$(tab: chrome.tabs.Tab): Observable { + const pageDetailsFromTab$ = this.messageListener + .messages$(COLLECT_PAGE_DETAILS_RESPONSE_COMMAND) + .pipe( + filter( + (message) => + message.tab.id === tab.id && + message.sender === AutofillMessageSender.collectPageDetailsFromTabObservable, + ), + scan( + (acc, message) => [ + ...acc, + { + frameId: message.webExtSender.frameId, + tab: message.tab, + details: message.details, + }, + ], + [] as PageDetail[], + ), + ); + + void BrowserApi.tabSendMessage(tab, { + tab: tab, + command: AutofillMessageCommand.collectPageDetails, + sender: AutofillMessageSender.collectPageDetailsFromTabObservable, + }); + + return pageDetailsFromTab$; + } + /** * Triggers on installation of the extension Handles injecting * content scripts into all tabs that are currently open, and diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 9af1a14132..fe797dc8ba 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -889,6 +889,7 @@ export default class MainBackground { this.scriptInjectorService, this.accountService, this.authService, + messageListener, ); this.auditService = new AuditService(this.cryptoFunctionService, this.apiService); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index d61fa3b19c..c7b5ca9b41 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -342,6 +342,7 @@ const safeProviders: SafeProvider[] = [ ScriptInjectorService, AccountServiceAbstraction, AuthService, + MessageListener, ], }), safeProvider({ diff --git a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts index 24ca030284..97856a952c 100644 --- a/apps/browser/src/vault/popup/components/vault/current-tab.component.ts +++ b/apps/browser/src/vault/popup/components/vault/current-tab.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { Router } from "@angular/router"; -import { Subject, firstValueFrom, from } from "rxjs"; +import { Subject, firstValueFrom, from, Subscription } from "rxjs"; import { debounceTime, switchMap, takeUntil } from "rxjs/operators"; import { UnassignedItemsBannerService } from "@bitwarden/angular/services/unassigned-items-banner.service"; @@ -51,12 +51,12 @@ export class CurrentTabComponent implements OnInit, OnDestroy { autofillCalloutText: string; protected search$ = new Subject(); private destroy$ = new Subject(); + private collectPageDetailsSubscription: Subscription; private totpCode: string; private totpTimeout: number; private loadedTimeout: number; private searchTimeout: number; - private initPageDetailsTimeout: number; protected unassignedItemsBannerEnabled$ = this.configService.getFeatureFlag$( FeatureFlag.UnassignedItemsBanner, @@ -100,15 +100,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy { }, 500); } break; - case "collectPageDetailsResponse": - if (message.sender === BroadcasterSubscriptionId) { - this.pageDetails.push({ - frameId: message.webExtSender.frameId, - tab: message.tab, - details: message.details, - }); - } - break; default: break; } @@ -266,6 +257,7 @@ export class CurrentTabComponent implements OnInit, OnDestroy { protected async load() { this.isLoading = false; this.tab = await BrowserApi.getTabFromCurrentWindow(); + if (this.tab != null) { this.url = this.tab.url; } else { @@ -274,8 +266,14 @@ export class CurrentTabComponent implements OnInit, OnDestroy { return; } - this.hostname = Utils.getHostname(this.url); this.pageDetails = []; + this.collectPageDetailsSubscription?.unsubscribe(); + this.collectPageDetailsSubscription = this.autofillService + .collectPageDetailsFromTab$(this.tab) + .pipe(takeUntil(this.destroy$)) + .subscribe((pageDetails) => (this.pageDetails = pageDetails)); + + this.hostname = Utils.getHostname(this.url); const otherTypes: CipherType[] = []; const dontShowCards = !(await firstValueFrom(this.vaultSettingsService.showCardsCurrentTab$)); const dontShowIdentities = !(await firstValueFrom( @@ -323,7 +321,6 @@ export class CurrentTabComponent implements OnInit, OnDestroy { } this.isLoading = this.loaded = true; - this.collectTabPageDetails(); } async goToSettings() { @@ -361,19 +358,4 @@ export class CurrentTabComponent implements OnInit, OnDestroy { this.autofillCalloutText = this.i18nService.t("autofillSelectInfoWithoutCommand"); } } - - private collectTabPageDetails() { - void BrowserApi.tabSendMessage(this.tab, { - command: "collectPageDetails", - tab: this.tab, - sender: BroadcasterSubscriptionId, - }); - - window.clearTimeout(this.initPageDetailsTimeout); - this.initPageDetailsTimeout = window.setTimeout(() => { - if (this.pageDetails.length === 0) { - this.collectTabPageDetails(); - } - }, 250); - } } diff --git a/apps/browser/src/vault/popup/components/vault/view.component.ts b/apps/browser/src/vault/popup/components/vault/view.component.ts index 211bd8fc09..ba101aa653 100644 --- a/apps/browser/src/vault/popup/components/vault/view.component.ts +++ b/apps/browser/src/vault/popup/components/vault/view.component.ts @@ -1,7 +1,7 @@ import { DatePipe, Location } from "@angular/common"; import { ChangeDetectorRef, Component, NgZone } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, firstValueFrom, takeUntil } from "rxjs"; +import { Subject, firstValueFrom, takeUntil, Subscription } from "rxjs"; import { first } from "rxjs/operators"; import { ViewComponent as BaseViewComponent } from "@bitwarden/angular/vault/components/view.component"; @@ -68,6 +68,7 @@ export class ViewComponent extends BaseViewComponent { inPopout = false; cipherType = CipherType; private fido2PopoutSessionData$ = fido2PopoutSessionData$(); + private collectPageDetailsSubscription: Subscription; private destroy$ = new Subject(); @@ -152,15 +153,6 @@ export class ViewComponent extends BaseViewComponent { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.ngZone.run(async () => { switch (message.command) { - case "collectPageDetailsResponse": - if (message.sender === BroadcasterSubscriptionId) { - this.pageDetails.push({ - frameId: message.webExtSender.frameId, - tab: message.tab, - details: message.details, - }); - } - break; case "tabChanged": case "windowChanged": if (this.loadPageDetailsTimeout != null) { @@ -337,6 +329,7 @@ export class ViewComponent extends BaseViewComponent { } private async loadPageDetails() { + this.collectPageDetailsSubscription?.unsubscribe(); this.pageDetails = []; this.tab = this.senderTabId ? await BrowserApi.getTab(this.senderTabId) @@ -346,13 +339,10 @@ export class ViewComponent extends BaseViewComponent { return; } - // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - BrowserApi.tabSendMessage(this.tab, { - command: "collectPageDetails", - tab: this.tab, - sender: BroadcasterSubscriptionId, - }); + this.collectPageDetailsSubscription = this.autofillService + .collectPageDetailsFromTab$(this.tab) + .pipe(takeUntil(this.destroy$)) + .subscribe((pageDetails) => (this.pageDetails = pageDetails)); } private async doAutofill() { From 882a432ca6ed823a3e3917e2a85dd11d076f6851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 11 Jun 2024 16:06:37 -0400 Subject: [PATCH 17/17] [PM-7289] implement generator libraries (#9549) This is a copy of the files. The source in `@bitwarden/common` will be deleted once all of the applications have been ported to the library. --- .../default-generator-navigation.service.ts | 2 +- .../src/tools/generator/rx-operators.ts | 41 +- .../rx-operators.spec.ts => rx.spec.ts} | 5 +- libs/common/src/tools/rx.ts | 38 + .../tools/generator/components/jest.config.js | 2 +- libs/tools/generator/core/jest.config.js | 2 +- .../generator-strategy.abstraction.ts | 42 + .../generator.service.abstraction.ts | 46 ++ .../generator/core/src/abstractions/index.ts | 6 + .../policy-evaluator.abstraction.ts | 28 + .../core/src/abstractions/randomizer.ts | 39 + .../core/src/data/default-addy-io-options.ts | 8 + .../core/src/data/default-catchall-options.ts | 8 + .../src/data/default-duck-duck-go-options.ts | 6 + .../src/data/default-eff-username-options.ts | 8 + .../core/src/data/default-fastmail-options.ts | 8 + .../src/data/default-firefox-relay-options.ts | 6 + .../src/data/default-forward-email-options.ts | 7 + .../src/data/default-passphrase-boundaries.ts | 15 + .../default-passphrase-generation-options.ts | 10 + .../src/data/default-password-boundaries.ts | 27 + .../default-password-generation-options.ts | 16 + .../src/data/default-simple-login-options.ts | 7 + .../default-subaddress-generator-options.ts | 8 + .../disabled-passphrase-generator-policy.ts | 8 + .../disabled-password-generator-policy.ts | 12 + .../generator/core/src/data/forwarders.ts | 49 ++ libs/tools/generator/core/src/data/index.ts | 17 + .../tools/generator/core/src/data/policies.ts | 29 + .../src/engine/crypto-service-randomizer.ts | 62 ++ libs/tools/generator/core/src/engine/index.ts | 1 + libs/tools/generator/core/src/factories.ts | 11 + libs/tools/generator/core/src/index.ts | 9 + .../policies/default-policy-evaluator.spec.ts | 43 + .../src/policies/default-policy-evaluator.ts | 27 + .../generator/core/src/policies/index.ts | 5 + ...phrase-generator-options-evaluator.spec.ts | 260 ++++++ .../passphrase-generator-options-evaluator.ts | 100 +++ .../passphrase-least-privilege.spec.ts | 53 ++ .../policies/passphrase-least-privilege.ts | 27 + ...ssword-generator-options-evaluator.spec.ts | 765 ++++++++++++++++++ .../password-generator-options-evaluator.ts | 157 ++++ .../policies/password-least-privilege.spec.ts | 57 ++ .../src/policies/password-least-privilege.ts | 28 + libs/tools/generator/core/src/rx.ts | 26 + .../default-generator.service.spec.ts | 194 +++++ .../src/services/default-generator.service.ts | 96 +++ .../generator/core/src/services/index.ts | 1 + .../catchall-generator-strategy.spec.ts | 75 ++ .../strategies/catchall-generator-strategy.ts | 50 ++ .../eff-username-generator-strategy.spec.ts | 75 ++ .../eff-username-generator-strategy.ts | 40 + .../forwarder-generator-strategy.spec.ts | 110 +++ .../forwarder-generator-strategy.ts | 95 +++ .../src/strategies/forwarders/addy-io.spec.ts | 233 ++++++ .../core/src/strategies/forwarders/addy-io.ts | 100 +++ .../forwarders/duck-duck-go.spec.ts | 144 ++++ .../src/strategies/forwarders/duck-duck-go.ts | 75 ++ .../strategies/forwarders/fastmail.spec.ts | 281 +++++++ .../src/strategies/forwarders/fastmail.ts | 150 ++++ .../forwarders/firefox-relay.spec.ts | 147 ++++ .../strategies/forwarders/firefox-relay.ts | 82 ++ .../forwarders/forward-email.spec.ts | 277 +++++++ .../strategies/forwarders/forward-email.ts | 104 +++ .../src/strategies/forwarders/mocks.jest.ts | 22 + .../forwarders/simple-login.spec.ts | 209 +++++ .../src/strategies/forwarders/simple-login.ts | 88 ++ .../generator/core/src/strategies/index.ts | 11 + .../passphrase-generator-strategy.spec.ts | 92 +++ .../passphrase-generator-strategy.ts | 66 ++ .../password-generator-strategy.spec.ts | 100 +++ .../strategies/password-generator-strategy.ts | 124 +++ .../core/src/strategies/storage.spec.ts | 169 ++++ .../generator/core/src/strategies/storage.ts | 184 +++++ .../subaddress-generator-strategy.spec.ts | 75 ++ .../subaddress-generator-strategy.ts | 62 ++ .../generator/core/src/types/boundary.ts | 4 + .../src/types/catchall-generator-options.ts | 14 + .../types/eff-username-generator-options.ts | 10 + .../core/src/types/forwarder-options.ts | 72 ++ .../core/src/types/generator-options.ts | 13 + .../core/src/types/generator-type.ts | 2 + libs/tools/generator/core/src/types/index.ts | 14 + .../generator/core/src/types/no-policy.ts | 2 + .../types/passphrase-generation-options.ts | 26 + .../src/types/passphrase-generator-policy.ts | 6 + .../src/types/password-generation-options.ts | 68 ++ .../src/types/password-generator-policy.ts | 39 + .../core/src/types/policy-configuration.ts | 16 + .../src/types/subaddress-generator-options.ts | 11 + .../generator/core/src/types/word-options.ts | 6 + libs/tools/generator/core/src/util.ts | 45 ++ libs/tools/generator/core/tsconfig.json | 6 +- .../tools/generator/extensions/jest.config.js | 2 +- .../src/history/generated-credential.spec.ts | 58 ++ .../src/history/generated-credential.ts | 47 ++ .../src/history/generated-password-history.ts | 9 + .../history/generator-history.abstraction.ts | 55 ++ .../generator/extensions/src/history/index.ts | 5 + .../src/history/key-definition.spec.ts | 65 ++ .../extensions/src/history/key-definitions.ts | 42 + .../legacy-password-history-decryptor.ts | 30 + .../local-generator-history.service.spec.ts | 200 +++++ .../local-generator-history.service.ts | 145 ++++ .../extensions/src/history/options.ts | 10 + libs/tools/generator/extensions/src/index.ts | 4 + .../extensions/src/legacy-password/factory.ts | 49 ++ .../extensions/src/legacy-password/index.ts | 3 + ...legacy-password-generation.service.spec.ts | 562 +++++++++++++ .../legacy-password-generation.service.ts | 381 +++++++++ ...password-generation.service.abstraction.ts | 22 + .../password-generator-options.ts | 10 + .../extensions/src/legacy-username/factory.ts | 110 +++ .../extensions/src/legacy-username/index.ts | 3 + ...legacy-username-generation.service.spec.ts | 749 +++++++++++++++++ .../legacy-username-generation.service.ts | 286 +++++++ .../username-generation-options.ts | 26 + ...username-generation.service.abstraction.ts | 15 + ...fault-generator-navigation.service.spec.ts | 98 +++ .../default-generator-navigation.service.ts | 73 ++ .../default-generator-navigation.ts | 8 + .../generator-navigation-evaluator.spec.ts | 64 ++ .../generator-navigation-evaluator.ts | 44 + .../generator-navigation-policy.spec.ts | 63 ++ .../navigation/generator-navigation-policy.ts | 39 + ...enerator-navigation.service.abstraction.ts | 42 + .../src/navigation/generator-navigation.ts | 16 + .../extensions/src/navigation/index.ts | 6 + .../src/navigation/key-definition.spec.ts | 11 + .../src/navigation/key-definitions.ts | 13 + 130 files changed, 9335 insertions(+), 46 deletions(-) rename libs/common/src/tools/{generator/rx-operators.spec.ts => rx.spec.ts} (94%) create mode 100644 libs/common/src/tools/rx.ts create mode 100644 libs/tools/generator/core/src/abstractions/generator-strategy.abstraction.ts create mode 100644 libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts create mode 100644 libs/tools/generator/core/src/abstractions/index.ts create mode 100644 libs/tools/generator/core/src/abstractions/policy-evaluator.abstraction.ts create mode 100644 libs/tools/generator/core/src/abstractions/randomizer.ts create mode 100644 libs/tools/generator/core/src/data/default-addy-io-options.ts create mode 100644 libs/tools/generator/core/src/data/default-catchall-options.ts create mode 100644 libs/tools/generator/core/src/data/default-duck-duck-go-options.ts create mode 100644 libs/tools/generator/core/src/data/default-eff-username-options.ts create mode 100644 libs/tools/generator/core/src/data/default-fastmail-options.ts create mode 100644 libs/tools/generator/core/src/data/default-firefox-relay-options.ts create mode 100644 libs/tools/generator/core/src/data/default-forward-email-options.ts create mode 100644 libs/tools/generator/core/src/data/default-passphrase-boundaries.ts create mode 100644 libs/tools/generator/core/src/data/default-passphrase-generation-options.ts create mode 100644 libs/tools/generator/core/src/data/default-password-boundaries.ts create mode 100644 libs/tools/generator/core/src/data/default-password-generation-options.ts create mode 100644 libs/tools/generator/core/src/data/default-simple-login-options.ts create mode 100644 libs/tools/generator/core/src/data/default-subaddress-generator-options.ts create mode 100644 libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts create mode 100644 libs/tools/generator/core/src/data/disabled-password-generator-policy.ts create mode 100644 libs/tools/generator/core/src/data/forwarders.ts create mode 100644 libs/tools/generator/core/src/data/index.ts create mode 100644 libs/tools/generator/core/src/data/policies.ts create mode 100644 libs/tools/generator/core/src/engine/crypto-service-randomizer.ts create mode 100644 libs/tools/generator/core/src/engine/index.ts create mode 100644 libs/tools/generator/core/src/factories.ts create mode 100644 libs/tools/generator/core/src/policies/default-policy-evaluator.spec.ts create mode 100644 libs/tools/generator/core/src/policies/default-policy-evaluator.ts create mode 100644 libs/tools/generator/core/src/policies/index.ts create mode 100644 libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts create mode 100644 libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts create mode 100644 libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts create mode 100644 libs/tools/generator/core/src/policies/passphrase-least-privilege.ts create mode 100644 libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts create mode 100644 libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts create mode 100644 libs/tools/generator/core/src/policies/password-least-privilege.spec.ts create mode 100644 libs/tools/generator/core/src/policies/password-least-privilege.ts create mode 100644 libs/tools/generator/core/src/rx.ts create mode 100644 libs/tools/generator/core/src/services/default-generator.service.spec.ts create mode 100644 libs/tools/generator/core/src/services/default-generator.service.ts create mode 100644 libs/tools/generator/core/src/services/index.ts create mode 100644 libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/addy-io.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/addy-io.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/fastmail.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/fastmail.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/firefox-relay.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/firefox-relay.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/forward-email.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/forward-email.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/mocks.jest.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/simple-login.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/forwarders/simple-login.ts create mode 100644 libs/tools/generator/core/src/strategies/index.ts create mode 100644 libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/password-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/strategies/storage.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/storage.ts create mode 100644 libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts create mode 100644 libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts create mode 100644 libs/tools/generator/core/src/types/boundary.ts create mode 100644 libs/tools/generator/core/src/types/catchall-generator-options.ts create mode 100644 libs/tools/generator/core/src/types/eff-username-generator-options.ts create mode 100644 libs/tools/generator/core/src/types/forwarder-options.ts create mode 100644 libs/tools/generator/core/src/types/generator-options.ts create mode 100644 libs/tools/generator/core/src/types/generator-type.ts create mode 100644 libs/tools/generator/core/src/types/index.ts create mode 100644 libs/tools/generator/core/src/types/no-policy.ts create mode 100644 libs/tools/generator/core/src/types/passphrase-generation-options.ts create mode 100644 libs/tools/generator/core/src/types/passphrase-generator-policy.ts create mode 100644 libs/tools/generator/core/src/types/password-generation-options.ts create mode 100644 libs/tools/generator/core/src/types/password-generator-policy.ts create mode 100644 libs/tools/generator/core/src/types/policy-configuration.ts create mode 100644 libs/tools/generator/core/src/types/subaddress-generator-options.ts create mode 100644 libs/tools/generator/core/src/types/word-options.ts create mode 100644 libs/tools/generator/core/src/util.ts create mode 100644 libs/tools/generator/extensions/src/history/generated-credential.spec.ts create mode 100644 libs/tools/generator/extensions/src/history/generated-credential.ts create mode 100644 libs/tools/generator/extensions/src/history/generated-password-history.ts create mode 100644 libs/tools/generator/extensions/src/history/generator-history.abstraction.ts create mode 100644 libs/tools/generator/extensions/src/history/index.ts create mode 100644 libs/tools/generator/extensions/src/history/key-definition.spec.ts create mode 100644 libs/tools/generator/extensions/src/history/key-definitions.ts create mode 100644 libs/tools/generator/extensions/src/history/legacy-password-history-decryptor.ts create mode 100644 libs/tools/generator/extensions/src/history/local-generator-history.service.spec.ts create mode 100644 libs/tools/generator/extensions/src/history/local-generator-history.service.ts create mode 100644 libs/tools/generator/extensions/src/history/options.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/factory.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/index.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.spec.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/password-generation.service.abstraction.ts create mode 100644 libs/tools/generator/extensions/src/legacy-password/password-generator-options.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/factory.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/index.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.spec.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/username-generation-options.ts create mode 100644 libs/tools/generator/extensions/src/legacy-username/username-generation.service.abstraction.ts create mode 100644 libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.spec.ts create mode 100644 libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.ts create mode 100644 libs/tools/generator/extensions/src/navigation/default-generator-navigation.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.spec.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation-policy.spec.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation-policy.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation.service.abstraction.ts create mode 100644 libs/tools/generator/extensions/src/navigation/generator-navigation.ts create mode 100644 libs/tools/generator/extensions/src/navigation/index.ts create mode 100644 libs/tools/generator/extensions/src/navigation/key-definition.spec.ts create mode 100644 libs/tools/generator/extensions/src/navigation/key-definitions.ts diff --git a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts index e5c259d841..a24f801271 100644 --- a/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts +++ b/libs/common/src/tools/generator/navigation/default-generator-navigation.service.ts @@ -4,9 +4,9 @@ import { PolicyService } from "../../../admin-console/abstractions/policy/policy import { PolicyType } from "../../../admin-console/enums"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { distinctIfShallowMatch, reduceCollection } from "../../rx"; import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction"; import { GENERATOR_SETTINGS } from "../key-definitions"; -import { distinctIfShallowMatch, reduceCollection } from "../rx-operators"; import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation"; import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; diff --git a/libs/common/src/tools/generator/rx-operators.ts b/libs/common/src/tools/generator/rx-operators.ts index 47233fa778..7793495344 100644 --- a/libs/common/src/tools/generator/rx-operators.ts +++ b/libs/common/src/tools/generator/rx-operators.ts @@ -1,45 +1,10 @@ -import { distinctUntilChanged, map, OperatorFunction, pipe } from "rxjs"; +import { map, pipe } from "rxjs"; + +import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; import { PolicyConfiguration } from "./policies"; -/** - * An observable operator that reduces an emitted collection to a single object, - * returning a default if all items are ignored. - * @param reduce The reduce function to apply to the filtered collection. The - * first argument is the accumulator, and the second is the current item. The - * return value is the new accumulator. - * @param defaultValue The default value to return if the collection is empty. The - * default value is also the initial value of the accumulator. - */ -export function reduceCollection( - reduce: (acc: Accumulator, value: Item) => Accumulator, - defaultValue: Accumulator, -): OperatorFunction { - return map((values: Item[]) => { - const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue)); - return reduced; - }); -} - -/** - * An observable operator that emits distinct values by checking that all - * values in the previous entry match the next entry. This method emits - * when a key is added and does not when a key is removed. - * @remarks This method checks objects. It does not check items in arrays. - */ -export function distinctIfShallowMatch(): OperatorFunction { - return distinctUntilChanged((previous, current) => { - let isDistinct = true; - - for (const key in current) { - isDistinct &&= previous[key] === current[key]; - } - - return isDistinct; - }); -} - /** Maps an administrative console policy to a policy evaluator using the provided configuration. * @param configuration the configuration that constructs the evaluator. */ diff --git a/libs/common/src/tools/generator/rx-operators.spec.ts b/libs/common/src/tools/rx.spec.ts similarity index 94% rename from libs/common/src/tools/generator/rx-operators.spec.ts rename to libs/common/src/tools/rx.spec.ts index 3d7dd4530f..8a2c1e38f5 100644 --- a/libs/common/src/tools/generator/rx-operators.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -2,12 +2,11 @@ * include structuredClone in test environment. * @jest-environment ../../../../shared/test.environment.ts */ - import { of, firstValueFrom } from "rxjs"; -import { awaitAsync, trackEmissions } from "../../../spec"; +import { awaitAsync, trackEmissions } from "../../spec"; -import { distinctIfShallowMatch, reduceCollection } from "./rx-operators"; +import { distinctIfShallowMatch, reduceCollection } from "./rx"; describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts new file mode 100644 index 0000000000..d2c5747a88 --- /dev/null +++ b/libs/common/src/tools/rx.ts @@ -0,0 +1,38 @@ +import { map, distinctUntilChanged, OperatorFunction } from "rxjs"; + +/** + * An observable operator that reduces an emitted collection to a single object, + * returning a default if all items are ignored. + * @param reduce The reduce function to apply to the filtered collection. The + * first argument is the accumulator, and the second is the current item. The + * return value is the new accumulator. + * @param defaultValue The default value to return if the collection is empty. The + * default value is also the initial value of the accumulator. + */ +export function reduceCollection( + reduce: (acc: Accumulator, value: Item) => Accumulator, + defaultValue: Accumulator, +): OperatorFunction { + return map((values: Item[]) => { + const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue)); + return reduced; + }); +} + +/** + * An observable operator that emits distinct values by checking that all + * values in the previous entry match the next entry. This method emits + * when a key is added and does not when a key is removed. + * @remarks This method checks objects. It does not check items in arrays. + */ +export function distinctIfShallowMatch(): OperatorFunction { + return distinctUntilChanged((previous, current) => { + let isDistinct = true; + + for (const key in current) { + isDistinct &&= previous[key] === current[key]; + } + + return isDistinct; + }); +} diff --git a/libs/tools/generator/components/jest.config.js b/libs/tools/generator/components/jest.config.js index 4a09c6360b..c34d909fe8 100644 --- a/libs/tools/generator/components/jest.config.js +++ b/libs/tools/generator/components/jest.config.js @@ -8,6 +8,6 @@ module.exports = { preset: "ts-jest", testEnvironment: "../../../shared/test.environment.ts", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/../../../", + prefix: "/../../", }), }; diff --git a/libs/tools/generator/core/jest.config.js b/libs/tools/generator/core/jest.config.js index 91d379b0c3..71ccbc80b6 100644 --- a/libs/tools/generator/core/jest.config.js +++ b/libs/tools/generator/core/jest.config.js @@ -8,6 +8,6 @@ module.exports = { preset: "ts-jest", testEnvironment: "../../../shared/test.environment.ts", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/../../../", + prefix: "/../../", }), }; diff --git a/libs/tools/generator/core/src/abstractions/generator-strategy.abstraction.ts b/libs/tools/generator/core/src/abstractions/generator-strategy.abstraction.ts new file mode 100644 index 0000000000..ff2d50c195 --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/generator-strategy.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { SingleUserState } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { PolicyEvaluator } from "./policy-evaluator.abstraction"; + +/** Tailors the generator service to generate a specific kind of credentials */ +export abstract class GeneratorStrategy { + /** Retrieve application state that persists across locks. + * @param userId: identifies the user state to retrieve + * @returns the strategy's durable user state + */ + durableState: (userId: UserId) => SingleUserState; + + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + + /** Identifies the policy enforced by the generator. */ + policy: PolicyType; + + /** Operator function that converts a policy collection observable to a single + * policy evaluator observable. + * @param policy The policy being evaluated. + * @returns the policy evaluator. If `policy` is is `null` or `undefined`, + * then the evaluator defaults to the application's limits. + * @throws when the policy's type does not match the generator's policy type. + */ + toEvaluator: () => ( + source: Observable, + ) => Observable>; + + /** Generates credentials from the given options. + * @param options The options used to generate the credentials. + * @returns a promise that resolves to the generated credentials. + */ + generate: (options: Options) => Promise; +} diff --git a/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts new file mode 100644 index 0000000000..4e0c929f04 --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/generator.service.abstraction.ts @@ -0,0 +1,46 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { PolicyEvaluator } from "./policy-evaluator.abstraction"; + +/** Generates credentials used for user authentication + * @typeParam Options the credential generation configuration + * @typeParam Policy the policy enforced by the generator + */ +export abstract class GeneratorService { + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + * @param userId: Identifies the user making the request + */ + options$: (userId: UserId) => Observable; + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + * @param userId: Identifies the user making the request + */ + evaluator$: (userId: UserId) => Observable>; + + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + + /** Enforces the policy on the given options + * @param userId: Identifies the user making the request + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + enforcePolicy: (userId: UserId, options: Options) => Promise; + + /** Generates credentials + * @param options the options to generate credentials with + * @returns a promise that resolves with the generated credentials + */ + generate: (options: Options) => Promise; + + /** Saves the given options to disk. + * @param userId: Identifies the user making the request + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + saveOptions: (userId: UserId, options: Options) => Promise; +} diff --git a/libs/tools/generator/core/src/abstractions/index.ts b/libs/tools/generator/core/src/abstractions/index.ts new file mode 100644 index 0000000000..ea9cc39f04 --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/index.ts @@ -0,0 +1,6 @@ +export { GeneratorHistoryService } from "../../../extensions/src/history/generator-history.abstraction"; +export { GeneratorNavigationService } from "../../../extensions/src/navigation/generator-navigation.service.abstraction"; +export { GeneratorService } from "./generator.service.abstraction"; +export { GeneratorStrategy } from "./generator-strategy.abstraction"; +export { PolicyEvaluator } from "./policy-evaluator.abstraction"; +export { Randomizer } from "./randomizer"; diff --git a/libs/tools/generator/core/src/abstractions/policy-evaluator.abstraction.ts b/libs/tools/generator/core/src/abstractions/policy-evaluator.abstraction.ts new file mode 100644 index 0000000000..f4e9186c9c --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/policy-evaluator.abstraction.ts @@ -0,0 +1,28 @@ +/** Applies policy to a generation request */ +export abstract class PolicyEvaluator { + /** The policy to enforce */ + policy: Policy; + + /** Returns true when a policy is being enforced by the evaluator. + * @remarks `applyPolicy` should be called when a policy is not in + * effect to enforce the application's default policy. + */ + policyInEffect: boolean; + + /** Apply policy to a set of options. + * @param options The options to build from. These options are not altered. + * @returns A complete generation request with policy applied. + * @remarks This method only applies policy overrides. + * Pass the result to `sanitize` to ensure consistency. + */ + applyPolicy: (options: PolicyTarget) => PolicyTarget; + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A new generation request with cascade applied. + * @remarks This method fills null and undefined values by looking at + * pairs of flags and values (e.g. `number` and `minNumber`). If the flag + * and value are inconsistent, the flag cascades to the value. + */ + sanitize: (options: PolicyTarget) => PolicyTarget; +} diff --git a/libs/tools/generator/core/src/abstractions/randomizer.ts b/libs/tools/generator/core/src/abstractions/randomizer.ts new file mode 100644 index 0000000000..e489f61d9b --- /dev/null +++ b/libs/tools/generator/core/src/abstractions/randomizer.ts @@ -0,0 +1,39 @@ +import { WordOptions } from "../types"; + +/** Entropy source for credential generation. */ +export interface Randomizer { + /** picks a random entry from a list. + * @param list random entry source. This must have at least one entry. + * @returns a promise that resolves with a random entry from the list. + */ + pick(list: Array): Promise; + + /** picks a random word from a list. + * @param list random entry source. This must have at least one entry. + * @param options customizes the output word + * @returns a promise that resolves with a random word from the list. + */ + pickWord(list: Array, options?: WordOptions): Promise; + + /** Shuffles a list of items + * @param list random entry source. This must have at least two entries. + * @param options.copy shuffles a copy of the input when this is true. + * Defaults to true. + * @returns a promise that resolves with the randomized list. + */ + shuffle(items: Array): Promise>; + + /** Generates a string containing random lowercase ASCII characters and numbers. + * @param length the number of characters to generate + * @returns a promise that resolves with the randomized string. + */ + chars(length: number): Promise; + + /** Selects an integer value from a range by randomly choosing it from + * a uniform distribution. + * @param min the minimum value in the range, inclusive. + * @param max the minimum value in the range, inclusive. + * @returns a promise that resolves with the randomized string. + */ + uniform(min: number, max: number): Promise; +} diff --git a/libs/tools/generator/core/src/data/default-addy-io-options.ts b/libs/tools/generator/core/src/data/default-addy-io-options.ts new file mode 100644 index 0000000000..2ebeefff6a --- /dev/null +++ b/libs/tools/generator/core/src/data/default-addy-io-options.ts @@ -0,0 +1,8 @@ +import { EmailDomainOptions, SelfHostedApiOptions } from "../types"; + +export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({ + website: null, + baseUrl: "https://app.addy.io", + token: "", + domain: "", +}); diff --git a/libs/tools/generator/core/src/data/default-catchall-options.ts b/libs/tools/generator/core/src/data/default-catchall-options.ts new file mode 100644 index 0000000000..9592c1538d --- /dev/null +++ b/libs/tools/generator/core/src/data/default-catchall-options.ts @@ -0,0 +1,8 @@ +import { CatchallGenerationOptions } from "../types"; + +/** The default options for catchall address generation. */ +export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({ + catchallType: "random", + catchallDomain: "", + website: null, +}); diff --git a/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts b/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts new file mode 100644 index 0000000000..c600e6e512 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-duck-duck-go-options.ts @@ -0,0 +1,6 @@ +import { ApiOptions } from "../types"; + +export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({ + website: null, + token: "", +}); diff --git a/libs/tools/generator/core/src/data/default-eff-username-options.ts b/libs/tools/generator/core/src/data/default-eff-username-options.ts new file mode 100644 index 0000000000..466e628049 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-eff-username-options.ts @@ -0,0 +1,8 @@ +import { EffUsernameGenerationOptions } from "../types"; + +/** The default options for EFF long word generation. */ +export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({ + wordCapitalize: false, + wordIncludeNumber: false, + website: null, +}); diff --git a/libs/tools/generator/core/src/data/default-fastmail-options.ts b/libs/tools/generator/core/src/data/default-fastmail-options.ts new file mode 100644 index 0000000000..18faefc464 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-fastmail-options.ts @@ -0,0 +1,8 @@ +import { ApiOptions, EmailPrefixOptions } from "../types"; + +export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({ + website: "", + domain: "", + prefix: "", + token: "", +}); diff --git a/libs/tools/generator/core/src/data/default-firefox-relay-options.ts b/libs/tools/generator/core/src/data/default-firefox-relay-options.ts new file mode 100644 index 0000000000..20433a3e12 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-firefox-relay-options.ts @@ -0,0 +1,6 @@ +import { ApiOptions } from "../types"; + +export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({ + website: null, + token: "", +}); diff --git a/libs/tools/generator/core/src/data/default-forward-email-options.ts b/libs/tools/generator/core/src/data/default-forward-email-options.ts new file mode 100644 index 0000000000..d5175534a0 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-forward-email-options.ts @@ -0,0 +1,7 @@ +import { ApiOptions, EmailDomainOptions } from "../types"; + +export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({ + website: null, + token: "", + domain: "", +}); diff --git a/libs/tools/generator/core/src/data/default-passphrase-boundaries.ts b/libs/tools/generator/core/src/data/default-passphrase-boundaries.ts new file mode 100644 index 0000000000..d4aca71709 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-passphrase-boundaries.ts @@ -0,0 +1,15 @@ +function initializeBoundaries() { + const numWords = Object.freeze({ + min: 3, + max: 20, + }); + + return Object.freeze({ + numWords, + }); +} + +/** Immutable default boundaries for passphrase generation. + * These are used when the policy does not override a value. + */ +export const DefaultPassphraseBoundaries = initializeBoundaries(); diff --git a/libs/tools/generator/core/src/data/default-passphrase-generation-options.ts b/libs/tools/generator/core/src/data/default-passphrase-generation-options.ts new file mode 100644 index 0000000000..59fb606900 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-passphrase-generation-options.ts @@ -0,0 +1,10 @@ +import { PassphraseGenerationOptions } from "../types"; + +/** The default options for passphrase generation. */ +export const DefaultPassphraseGenerationOptions: Partial = + Object.freeze({ + numWords: 3, + wordSeparator: "-", + capitalize: false, + includeNumber: false, + }); diff --git a/libs/tools/generator/core/src/data/default-password-boundaries.ts b/libs/tools/generator/core/src/data/default-password-boundaries.ts new file mode 100644 index 0000000000..fc23a1b530 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-password-boundaries.ts @@ -0,0 +1,27 @@ +function initializeBoundaries() { + const length = Object.freeze({ + min: 5, + max: 128, + }); + + const minDigits = Object.freeze({ + min: 0, + max: 9, + }); + + const minSpecialCharacters = Object.freeze({ + min: 0, + max: 9, + }); + + return Object.freeze({ + length, + minDigits, + minSpecialCharacters, + }); +} + +/** Immutable default boundaries for password generation. + * These are used when the policy does not override a value. + */ +export const DefaultPasswordBoundaries = initializeBoundaries(); diff --git a/libs/tools/generator/core/src/data/default-password-generation-options.ts b/libs/tools/generator/core/src/data/default-password-generation-options.ts new file mode 100644 index 0000000000..00dd60c6fd --- /dev/null +++ b/libs/tools/generator/core/src/data/default-password-generation-options.ts @@ -0,0 +1,16 @@ +import { PasswordGenerationOptions } from "../types"; + +import { DefaultPasswordBoundaries } from "./default-password-boundaries"; + +/** The default options for password generation. */ +export const DefaultPasswordGenerationOptions: Partial = Object.freeze({ + length: 14, + minLength: DefaultPasswordBoundaries.length.min, + ambiguous: true, + uppercase: true, + lowercase: true, + number: true, + minNumber: 1, + special: false, + minSpecial: 0, +}); diff --git a/libs/tools/generator/core/src/data/default-simple-login-options.ts b/libs/tools/generator/core/src/data/default-simple-login-options.ts new file mode 100644 index 0000000000..965b1222cd --- /dev/null +++ b/libs/tools/generator/core/src/data/default-simple-login-options.ts @@ -0,0 +1,7 @@ +import { SelfHostedApiOptions } from "../types"; + +export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({ + website: null, + baseUrl: "https://app.simplelogin.io", + token: "", +}); diff --git a/libs/tools/generator/core/src/data/default-subaddress-generator-options.ts b/libs/tools/generator/core/src/data/default-subaddress-generator-options.ts new file mode 100644 index 0000000000..f375fa3864 --- /dev/null +++ b/libs/tools/generator/core/src/data/default-subaddress-generator-options.ts @@ -0,0 +1,8 @@ +import { SubaddressGenerationOptions } from "../types"; + +/** The default options for email subaddress generation. */ +export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({ + subaddressType: "random", + subaddressEmail: "", + website: null, +}); diff --git a/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts b/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts new file mode 100644 index 0000000000..2eb77a2cd4 --- /dev/null +++ b/libs/tools/generator/core/src/data/disabled-passphrase-generator-policy.ts @@ -0,0 +1,8 @@ +import { PassphraseGeneratorPolicy } from "../types"; + +/** The default options for password generation policy. */ +export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({ + minNumberWords: 0, + capitalize: false, + includeNumber: false, +}); diff --git a/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts b/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts new file mode 100644 index 0000000000..4fc921975c --- /dev/null +++ b/libs/tools/generator/core/src/data/disabled-password-generator-policy.ts @@ -0,0 +1,12 @@ +import { PasswordGeneratorPolicy } from "../types"; + +/** The default options for password generation policy. */ +export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({ + minLength: 0, + useUppercase: false, + useLowercase: false, + useNumbers: false, + numberCount: 0, + useSpecial: false, + specialCount: 0, +}); diff --git a/libs/tools/generator/core/src/data/forwarders.ts b/libs/tools/generator/core/src/data/forwarders.ts new file mode 100644 index 0000000000..e833fbf41d --- /dev/null +++ b/libs/tools/generator/core/src/data/forwarders.ts @@ -0,0 +1,49 @@ +import { ForwarderMetadata } from "../types"; + +/** Metadata about an email forwarding service. + * @remarks This is used to populate the forwarder selection list + * and to identify forwarding services in error messages. + */ +export const Forwarders = Object.freeze({ + /** For https://addy.io/ */ + AddyIo: Object.freeze({ + id: "anonaddy", + name: "Addy.io", + validForSelfHosted: true, + } as ForwarderMetadata), + + /** For https://duckduckgo.com/email/ */ + DuckDuckGo: Object.freeze({ + id: "duckduckgo", + name: "DuckDuckGo", + validForSelfHosted: false, + } as ForwarderMetadata), + + /** For https://www.fastmail.com. */ + Fastmail: Object.freeze({ + id: "fastmail", + name: "Fastmail", + validForSelfHosted: true, + } as ForwarderMetadata), + + /** For https://relay.firefox.com/ */ + FirefoxRelay: Object.freeze({ + id: "firefoxrelay", + name: "Firefox Relay", + validForSelfHosted: false, + } as ForwarderMetadata), + + /** For https://forwardemail.net/ */ + ForwardEmail: Object.freeze({ + id: "forwardemail", + name: "Forward Email", + validForSelfHosted: true, + } as ForwarderMetadata), + + /** For https://simplelogin.io/ */ + SimpleLogin: Object.freeze({ + id: "simplelogin", + name: "SimpleLogin", + validForSelfHosted: true, + } as ForwarderMetadata), +}); diff --git a/libs/tools/generator/core/src/data/index.ts b/libs/tools/generator/core/src/data/index.ts new file mode 100644 index 0000000000..22386f58a7 --- /dev/null +++ b/libs/tools/generator/core/src/data/index.ts @@ -0,0 +1,17 @@ +export * from "./default-addy-io-options"; +export * from "./default-catchall-options"; +export * from "./default-duck-duck-go-options"; +export * from "./default-fastmail-options"; +export * from "./default-forward-email-options"; +export * from "./default-passphrase-boundaries"; +export * from "./default-password-boundaries"; +export * from "./default-eff-username-options"; +export * from "./default-firefox-relay-options"; +export * from "./default-passphrase-generation-options"; +export * from "./default-password-generation-options"; +export * from "./default-subaddress-generator-options"; +export * from "./default-simple-login-options"; +export * from "./disabled-passphrase-generator-policy"; +export * from "./disabled-password-generator-policy"; +export * from "./forwarders"; +export * from "./policies"; diff --git a/libs/tools/generator/core/src/data/policies.ts b/libs/tools/generator/core/src/data/policies.ts new file mode 100644 index 0000000000..e7271e5616 --- /dev/null +++ b/libs/tools/generator/core/src/data/policies.ts @@ -0,0 +1,29 @@ +import { DisabledPassphraseGeneratorPolicy, DisabledPasswordGeneratorPolicy } from "../data"; +import { + passphraseLeastPrivilege, + passwordLeastPrivilege, + PassphraseGeneratorOptionsEvaluator, + PasswordGeneratorOptionsEvaluator, +} from "../policies"; +import { PassphraseGeneratorPolicy, PasswordGeneratorPolicy, PolicyConfiguration } from "../types"; + +const PASSPHRASE = Object.freeze({ + disabledValue: DisabledPassphraseGeneratorPolicy, + combine: passphraseLeastPrivilege, + createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy), +} as PolicyConfiguration); + +const PASSWORD = Object.freeze({ + disabledValue: DisabledPasswordGeneratorPolicy, + combine: passwordLeastPrivilege, + createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy), +} as PolicyConfiguration); + +/** Policy configurations */ +export const Policies = Object.freeze({ + /** Passphrase policy configuration */ + Passphrase: PASSPHRASE, + + /** Passphrase policy configuration */ + Password: PASSWORD, +}); diff --git a/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts b/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts new file mode 100644 index 0000000000..8cc8854cbc --- /dev/null +++ b/libs/tools/generator/core/src/engine/crypto-service-randomizer.ts @@ -0,0 +1,62 @@ +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +import { Randomizer } from "../abstractions"; +import { WordOptions } from "../types"; + +/** A randomizer backed by a CryptoService. */ +export class CryptoServiceRandomizer implements Randomizer { + constructor(private crypto: CryptoService) {} + + async pick(list: Array) { + const index = await this.uniform(0, list.length - 1); + return list[index]; + } + + async pickWord(list: Array, options?: WordOptions) { + let word = await this.pick(list); + + if (options?.titleCase ?? false) { + word = word.charAt(0).toUpperCase() + word.slice(1); + } + + if (options?.number ?? false) { + const num = await this.crypto.randomNumber(1, 9999); + word = word + this.zeroPad(num.toString(), 4); + } + + return word; + } + + // ref: https://stackoverflow.com/a/12646864/1090359 + async shuffle(items: Array, options?: { copy?: boolean }) { + const shuffled = options?.copy ?? true ? [...items] : items; + + for (let i = items.length - 1; i > 0; i--) { + const j = await this.uniform(0, i); + [items[i], items[j]] = [items[j], items[i]]; + } + + return shuffled; + } + + async chars(length: number) { + let str = ""; + const charSet = "abcdefghijklmnopqrstuvwxyz1234567890"; + for (let i = 0; i < length; i++) { + const randomCharIndex = await this.uniform(0, charSet.length - 1); + str += charSet.charAt(randomCharIndex); + } + return str; + } + + async uniform(min: number, max: number) { + return this.crypto.randomNumber(min, max); + } + + // ref: https://stackoverflow.com/a/10073788 + private zeroPad(number: string, width: number) { + return number.length >= width + ? number + : new Array(width - number.length + 1).join("0") + number; + } +} diff --git a/libs/tools/generator/core/src/engine/index.ts b/libs/tools/generator/core/src/engine/index.ts new file mode 100644 index 0000000000..1a67384de1 --- /dev/null +++ b/libs/tools/generator/core/src/engine/index.ts @@ -0,0 +1 @@ +export { CryptoServiceRandomizer } from "./crypto-service-randomizer"; diff --git a/libs/tools/generator/core/src/factories.ts b/libs/tools/generator/core/src/factories.ts new file mode 100644 index 0000000000..6c09b8d315 --- /dev/null +++ b/libs/tools/generator/core/src/factories.ts @@ -0,0 +1,11 @@ +// contains logic that constructs generator services dynamically given +// a generator id. + +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; + +import { Randomizer } from "./abstractions"; +import { CryptoServiceRandomizer } from "./engine/crypto-service-randomizer"; + +export function createRandomizer(cryptoService: CryptoService): Randomizer { + return new CryptoServiceRandomizer(cryptoService); +} diff --git a/libs/tools/generator/core/src/index.ts b/libs/tools/generator/core/src/index.ts index e69de29bb2..c01faaece9 100644 --- a/libs/tools/generator/core/src/index.ts +++ b/libs/tools/generator/core/src/index.ts @@ -0,0 +1,9 @@ +export * from "./abstractions"; +export * from "./data"; +export { createRandomizer } from "./factories"; +export * as engine from "./engine"; +export * as policies from "./policies"; +export * as rx from "./rx"; +export * as services from "./services"; +export * as strategies from "./strategies"; +export * from "./types"; diff --git a/libs/tools/generator/core/src/policies/default-policy-evaluator.spec.ts b/libs/tools/generator/core/src/policies/default-policy-evaluator.spec.ts new file mode 100644 index 0000000000..d5d5e81028 --- /dev/null +++ b/libs/tools/generator/core/src/policies/default-policy-evaluator.spec.ts @@ -0,0 +1,43 @@ +import { DefaultPolicyEvaluator } from "./default-policy-evaluator"; + +describe("Password generator options builder", () => { + describe("policy", () => { + it("should return an empty object", () => { + const builder = new DefaultPolicyEvaluator(); + + expect(builder.policy).toEqual({}); + }); + }); + + describe("policyInEffect", () => { + it("should return false", () => { + const builder = new DefaultPolicyEvaluator(); + + expect(builder.policyInEffect).toEqual(false); + }); + }); + + describe("applyPolicy(options)", () => { + // All tests should freeze the options to ensure they are not modified + it("should return the input operations without altering them", () => { + const builder = new DefaultPolicyEvaluator(); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions).toEqual(options); + }); + }); + + describe("sanitize(options)", () => { + // All tests should freeze the options to ensure they are not modified + it("should return the input options without altering them", () => { + const builder = new DefaultPolicyEvaluator(); + const options = Object.freeze({}); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions).toEqual(options); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/default-policy-evaluator.ts b/libs/tools/generator/core/src/policies/default-policy-evaluator.ts new file mode 100644 index 0000000000..384b3bc1ae --- /dev/null +++ b/libs/tools/generator/core/src/policies/default-policy-evaluator.ts @@ -0,0 +1,27 @@ +import { PolicyEvaluator } from "../abstractions"; +import { NoPolicy } from "../types"; + +/** A policy evaluator that does not apply any policy */ +export class DefaultPolicyEvaluator + implements PolicyEvaluator +{ + /** {@link PolicyEvaluator.policy} */ + get policy() { + return {}; + } + + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect() { + return false; + } + + /** {@link PolicyEvaluator.applyPolicy} */ + applyPolicy(options: PolicyTarget) { + return options; + } + + /** {@link PolicyEvaluator.sanitize} */ + sanitize(options: PolicyTarget) { + return options; + } +} diff --git a/libs/tools/generator/core/src/policies/index.ts b/libs/tools/generator/core/src/policies/index.ts new file mode 100644 index 0000000000..bce363e6da --- /dev/null +++ b/libs/tools/generator/core/src/policies/index.ts @@ -0,0 +1,5 @@ +export { DefaultPolicyEvaluator } from "./default-policy-evaluator"; +export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; +export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; +export { passphraseLeastPrivilege } from "./passphrase-least-privilege"; +export { passwordLeastPrivilege } from "./password-least-privilege"; diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts new file mode 100644 index 0000000000..3a57df70df --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.spec.ts @@ -0,0 +1,260 @@ +import { DisabledPassphraseGeneratorPolicy, DefaultPassphraseBoundaries } from "../data"; +import { PassphraseGenerationOptions } from "../types"; + +import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator"; + +describe("Password generator options builder", () => { + describe("constructor()", () => { + it("should set the policy object to a copy of the input policy", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = 10; // arbitrary change for deep equality check + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policy).toEqual(policy); + expect(builder.policy).not.toBe(policy); + }); + + it("should set default boundaries when a default policy is used", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords); + }); + + it.each([1, 2])( + "should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)", + (minNumberWords) => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords); + }, + ); + + it.each([8, 12, 18])( + "should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words", + (minNumberWords) => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords.min).toEqual(minNumberWords); + expect(builder.numWords.max).toEqual(DefaultPassphraseBoundaries.numWords.max); + }, + ); + + it.each([150, 300, 9000])( + "should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries", + (minNumberWords) => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = minNumberWords; + + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.numWords.min).toEqual(minNumberWords); + expect(builder.numWords.max).toEqual(minNumberWords); + }, + ); + }); + + describe("policyInEffect", () => { + it("should return false when the policy has no effect", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(false); + }); + + it("should return true when the policy has a numWords greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has capitalize enabled", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.capitalize = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has includeNumber enabled", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.includeNumber = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + }); + + describe("applyPolicy(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it("should set `capitalize` to `false` when the policy does not override it", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.capitalize).toBe(false); + }); + + it("should set `capitalize` to `true` when the policy overrides it", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.capitalize = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ capitalize: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.capitalize).toBe(true); + }); + + it("should set `includeNumber` to false when the policy does not override it", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.includeNumber).toBe(false); + }); + + it("should set `includeNumber` to true when the policy overrides it", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + policy.includeNumber = true; + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ includeNumber: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.includeNumber).toBe(true); + }); + + it("should set `numWords` to the minimum value when it isn't supplied", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.min); + }); + + it.each([1, 2])( + "should set `numWords` (= %i) to the minimum value when it is less than the minimum", + (numWords) => { + expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min); + + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.min); + }, + ); + + it.each([3, 8, 18, 20])( + "should set `numWords` (= %i) to the input value when it is within the boundaries", + (numWords) => { + expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min); + expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max); + + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(numWords); + }, + ); + + it.each([21, 30, 50, 100])( + "should set `numWords` (= %i) to the maximum value when it is greater than the maximum", + (numWords) => { + expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max); + + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ numWords }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.numWords).toBe(builder.numWords.max); + }, + ); + + it("should preserve unknown properties", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PassphraseGenerationOptions; + + const sanitizedOptions: any = builder.applyPolicy(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); + + describe("sanitize(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it("should return the input options without altering them", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ wordSeparator: "%" }); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions).toEqual(options); + }); + + it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({}); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions.wordSeparator).toEqual("-"); + }); + + it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ wordSeparator: "" }); + + const sanitizedOptions = builder.sanitize(options); + + expect(sanitizedOptions.wordSeparator).toEqual(""); + }); + + it("should preserve unknown properties", () => { + const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy); + const builder = new PassphraseGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PassphraseGenerationOptions; + + const sanitizedOptions: any = builder.sanitize(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts new file mode 100644 index 0000000000..135d4574d8 --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-generator-options-evaluator.ts @@ -0,0 +1,100 @@ +import { PolicyEvaluator } from "../abstractions"; +import { DefaultPassphraseGenerationOptions, DefaultPassphraseBoundaries } from "../data"; +import { Boundary, PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; + +/** Enforces policy for passphrase generation options. + */ +export class PassphraseGeneratorOptionsEvaluator + implements PolicyEvaluator +{ + // This design is not ideal, but it is a step towards a more robust passphrase + // generator. Ideally, `sanitize` would be implemented on an options class, + // and `applyPolicy` would be implemented on a policy class, "mise en place". + // + // The current design of the passphrase generator, unfortunately, would require + // a substantial rewrite to make this feasible. Hopefully this change can be + // applied when the passphrase generator is ported to rust. + + /** Policy applied by the evaluator. + */ + readonly policy: PassphraseGeneratorPolicy; + + /** Boundaries for the number of words allowed in the password. + */ + readonly numWords: Boundary; + + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(policy: PassphraseGeneratorPolicy) { + function createBoundary(value: number, defaultBoundary: Boundary): Boundary { + const boundary = { + min: Math.max(defaultBoundary.min, value), + max: Math.max(defaultBoundary.max, value), + }; + + return boundary; + } + + this.policy = structuredClone(policy); + this.numWords = createBoundary(policy.minNumberWords, DefaultPassphraseBoundaries.numWords); + } + + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + const policies = [ + this.policy.capitalize, + this.policy.includeNumber, + this.policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min, + ]; + + return policies.includes(true); + } + + /** Apply policy to the input options. + * @param options The options to build from. These options are not altered. + * @returns A new password generation request with policy applied. + */ + applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions { + function fitToBounds(value: number, boundaries: Boundary) { + const { min, max } = boundaries; + + const withUpperBound = Math.min(value ?? boundaries.min, max); + const withLowerBound = Math.max(withUpperBound, min); + + return withLowerBound; + } + + // apply policy overrides + const capitalize = this.policy.capitalize || options.capitalize || false; + const includeNumber = this.policy.includeNumber || options.includeNumber || false; + + // apply boundaries + const numWords = fitToBounds(options.numWords, this.numWords); + + return { + ...options, + numWords, + capitalize, + includeNumber, + }; + } + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A passphrase generation request with cascade applied. + */ + sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions { + // ensure words are separated by a single character or the empty string + const wordSeparator = + options.wordSeparator === "" + ? "" + : options.wordSeparator?.[0] ?? DefaultPassphraseGenerationOptions.wordSeparator; + + return { + ...options, + wordSeparator, + }; + } +} diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts new file mode 100644 index 0000000000..4a330f032f --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.spec.ts @@ -0,0 +1,53 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { PolicyId } from "@bitwarden/common/types/guid"; + +import { DisabledPassphraseGeneratorPolicy } from "../data"; + +import { passphraseLeastPrivilege } from "./passphrase-least-privilege"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("passphraseLeastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPassphraseGeneratorPolicy); + }); + + it.each([ + ["minNumberWords", 10], + ["capitalize", true], + ["includeNumber", true], + ])("should take the %p from the policy", (input, value) => { + const policy = createPolicy({ [input]: value }); + + const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/passphrase-least-privilege.ts b/libs/tools/generator/core/src/policies/passphrase-least-privilege.ts new file mode 100644 index 0000000000..8f797d49b1 --- /dev/null +++ b/libs/tools/generator/core/src/policies/passphrase-least-privilege.ts @@ -0,0 +1,27 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { PassphraseGeneratorPolicy } from "../types"; + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function passphraseLeastPrivilege( + acc: PassphraseGeneratorPolicy, + policy: Policy, +): PassphraseGeneratorPolicy { + if (policy.type !== PolicyType.PasswordGenerator) { + return acc; + } + + return { + minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords), + capitalize: policy.data.capitalize || acc.capitalize, + includeNumber: policy.data.includeNumber || acc.includeNumber, + }; +} diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts new file mode 100644 index 0000000000..a703388f95 --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.spec.ts @@ -0,0 +1,765 @@ +import { DefaultPasswordBoundaries, DisabledPasswordGeneratorPolicy } from "../data"; +import { PasswordGenerationOptions } from "../types"; + +import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator"; + +describe("Password generator options builder", () => { + const defaultOptions = Object.freeze({ minLength: 0 }); + + describe("constructor()", () => { + it("should set the policy object to a copy of the input policy", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = 10; // arbitrary change for deep equality check + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policy).toEqual(policy); + expect(builder.policy).not.toBe(policy); + }); + + it("should set default boundaries when a default policy is used", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length).toEqual(DefaultPasswordBoundaries.length); + expect(builder.minDigits).toEqual(DefaultPasswordBoundaries.minDigits); + expect(builder.minSpecialCharacters).toEqual(DefaultPasswordBoundaries.minSpecialCharacters); + }); + + it.each([1, 2, 3, 4])( + "should use the default length boundaries when they are greater than `policy.minLength` (= %i)", + (minLength) => { + expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = minLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length).toEqual(DefaultPasswordBoundaries.length); + }, + ); + + it.each([8, 20, 100])( + "should use `policy.minLength` (= %i) when it is greater than the default minimum length", + (expectedLength) => { + expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min); + expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = expectedLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toEqual(expectedLength); + expect(builder.length.max).toEqual(DefaultPasswordBoundaries.length.max); + }, + ); + + it.each([150, 300, 9000])( + "should use `policy.minLength` (= %i) when it is greater than the default boundaries", + (expectedLength) => { + expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = expectedLength; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toEqual(expectedLength); + expect(builder.length.max).toEqual(expectedLength); + }, + ); + + it.each([3, 5, 8, 9])( + "should use `policy.numberCount` (= %i) when it is greater than the default minimum digits", + (expectedMinDigits) => { + expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min); + expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = expectedMinDigits; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minDigits.min).toEqual(expectedMinDigits); + expect(builder.minDigits.max).toEqual(DefaultPasswordBoundaries.minDigits.max); + }, + ); + + it.each([10, 20, 400])( + "should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries", + (expectedMinDigits) => { + expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = expectedMinDigits; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minDigits.min).toEqual(expectedMinDigits); + expect(builder.minDigits.max).toEqual(expectedMinDigits); + }, + ); + + it.each([2, 4, 6])( + "should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters", + (expectedSpecialCharacters) => { + expect(expectedSpecialCharacters).toBeGreaterThan( + DefaultPasswordBoundaries.minSpecialCharacters.min, + ); + expect(expectedSpecialCharacters).toBeLessThanOrEqual( + DefaultPasswordBoundaries.minSpecialCharacters.max, + ); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.specialCount = expectedSpecialCharacters; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); + expect(builder.minSpecialCharacters.max).toEqual( + DefaultPasswordBoundaries.minSpecialCharacters.max, + ); + }, + ); + + it.each([10, 20, 400])( + "should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries", + (expectedSpecialCharacters) => { + expect(expectedSpecialCharacters).toBeGreaterThan( + DefaultPasswordBoundaries.minSpecialCharacters.max, + ); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.specialCount = expectedSpecialCharacters; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters); + expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters); + }, + ); + + it.each([ + [8, 6, 2], + [6, 2, 4], + [16, 8, 8], + ])( + "should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)", + (expectedLength, numberCount, specialCount) => { + expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = numberCount; + policy.specialCount = specialCount; + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength); + }, + ); + }); + + describe("policyInEffect", () => { + it("should return false when the policy has no effect", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(false); + }); + + it("should return true when the policy has a minlength greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.minLength = DefaultPasswordBoundaries.length.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has a number count greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has a special character count greater than the default boundary", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has uppercase enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useUppercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has lowercase enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useLowercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has numbers enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useNumbers = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + + it("should return true when the policy has special characters enabled", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useSpecial = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + + expect(builder.policyInEffect).toEqual(true); + }); + }); + + describe("applyPolicy(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'", + (expectedUppercase, uppercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useUppercase = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, uppercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.uppercase).toEqual(expectedUppercase); + }, + ); + + it.each([false, true, undefined])( + "should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true", + (uppercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useUppercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, uppercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.uppercase).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'", + (expectedLowercase, lowercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useLowercase = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, lowercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.lowercase).toEqual(expectedLowercase); + }, + ); + + it.each([false, true, undefined])( + "should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true", + (lowercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useLowercase = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, lowercase }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.lowercase).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'", + (expectedNumber, number) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useNumbers = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(expectedNumber); + }, + ); + + it.each([false, true, undefined])( + "should set `options.number` (= %s) to true when `policy.useNumbers` is true", + (number) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useNumbers = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(true); + }, + ); + + it.each([ + [false, false], + [true, true], + [false, undefined], + ])( + "should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'", + (expectedSpecial, special) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useSpecial = false; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(expectedSpecial); + }, + ); + + it.each([false, true, undefined])( + "should set `options.special` (= %s) to true when `policy.useSpecial` is true", + (special) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.useSpecial = true; + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(true); + }, + ); + + it.each([1, 2, 3, 4])( + "should set `options.length` (= %i) to the minimum it is less than the minimum length", + (length) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeLessThan(builder.length.min); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(builder.length.min); + }, + ); + + it.each([5, 10, 50, 100, 128])( + "should not change `options.length` (= %i) when it is within the boundaries", + (length) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeGreaterThanOrEqual(builder.length.min); + expect(length).toBeLessThanOrEqual(builder.length.max); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(length); + }, + ); + + it.each([129, 500, 9000])( + "should set `options.length` (= %i) to the maximum length when it is exceeded", + (length) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(length).toBeGreaterThan(builder.length.max); + + const options = Object.freeze({ ...defaultOptions, length }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.length).toEqual(builder.length.max); + }, + ); + + it.each([ + [true, 1], + [true, 3], + [true, 600], + [false, 0], + [false, -2], + [false, -600], + ])( + "should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0", + (expectedNumber, minNumber) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.number).toEqual(expectedNumber); + }, + ); + + it("should set `options.minNumber` to the minimum value when `options.number` is true", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number: true }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); + }); + + it("should set `options.minNumber` to 0 when `options.number` is false", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, number: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(0); + }); + + it.each([1, 2, 3, 4])( + "should set `options.minNumber` (= %i) to the minimum it is less than the minimum number", + (minNumber) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.numberCount = 5; // arbitrary value greater than minNumber + expect(minNumber).toBeLessThan(policy.numberCount); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min); + }, + ); + + it.each([1, 3, 5, 7, 9])( + "should not change `options.minNumber` (= %i) when it is within the boundaries", + (minNumber) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min); + expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max); + + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(minNumber); + }, + ); + + it.each([10, 20, 400])( + "should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded", + (minNumber) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minNumber).toBeGreaterThan(builder.minDigits.max); + + const options = Object.freeze({ ...defaultOptions, minNumber }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max); + }, + ); + + it.each([ + [true, 1], + [true, 3], + [true, 600], + [false, 0], + [false, -2], + [false, -600], + ])( + "should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0", + (expectedSpecial, minSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.special).toEqual(expectedSpecial); + }, + ); + + it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special: true }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min); + }); + + it("should set `options.minSpecial` to 0 when `options.special` is false", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, special: false }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(0); + }); + + it.each([1, 2, 3, 4])( + "should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters", + (minSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + policy.specialCount = 5; // arbitrary value greater than minSpecial + expect(minSpecial).toBeLessThan(policy.specialCount); + + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min); + }, + ); + + it.each([1, 3, 5, 7, 9])( + "should not change `options.minSpecial` (= %i) when it is within the boundaries", + (minSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min); + expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max); + + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(minSpecial); + }, + ); + + it.each([10, 20, 400])( + "should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded", + (minSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max); + + const options = Object.freeze({ ...defaultOptions, minSpecial }); + + const sanitizedOptions = builder.applyPolicy(options); + + expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max); + }, + ); + + it("should preserve unknown properties", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PasswordGenerationOptions; + + const sanitizedOptions: any = builder.applyPolicy(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); + + describe("sanitize(options)", () => { + // All tests should freeze the options to ensure they are not modified + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minLowercase === %i` when `options.lowercase` is %s", + (expectedMinLowercase, lowercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ lowercase, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minLowercase).toEqual(expectedMinLowercase); + }, + ); + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minUppercase === %i` when `options.uppercase` is %s", + (expectedMinUppercase, uppercase) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ uppercase, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minUppercase).toEqual(expectedMinUppercase); + }, + ); + + it.each([ + [1, true], + [0, false], + ])( + "should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set", + (expectedMinNumber, number) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ number, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minNumber).toEqual(expectedMinNumber); + }, + ); + + it.each([ + [true, 3], + [true, 2], + [true, 1], + [false, 0], + ])( + "should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set", + (expectedNumber, minNumber) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ minNumber, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.number).toEqual(expectedNumber); + }, + ); + + it.each([ + [true, 1], + [false, 0], + ])( + "should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set", + (special, expectedMinSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ special, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.minSpecial).toEqual(expectedMinSpecial); + }, + ); + + it.each([ + [3, true], + [2, true], + [1, true], + [0, false], + ])( + "should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set", + (minSpecial, expectedSpecial) => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ minSpecial, ...defaultOptions }); + + const actual = builder.sanitize(options); + + expect(actual.special).toEqual(expectedSpecial); + }, + ); + + it.each([ + [0, 0, 0, 0], + [1, 1, 0, 0], + [0, 0, 1, 1], + [1, 1, 1, 1], + ])( + "should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.", + (minLowercase, minUppercase, minNumber, minSpecial) => { + const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial; + expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + minLowercase, + minUppercase, + minNumber, + minSpecial, + ...defaultOptions, + }); + + const actual = builder.sanitize(options); + + expect(actual.minLength).toEqual(builder.length.min); + }, + ); + + it.each([ + [12, 3, 3, 3, 3], + [8, 2, 2, 2, 2], + [9, 3, 3, 3, 0], + ])( + "should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.", + (expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => { + expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min); + + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + minLowercase, + minUppercase, + minNumber, + minSpecial, + ...defaultOptions, + }); + + const actual = builder.sanitize(options); + + expect(actual.minLength).toEqual(expectedMinLength); + }, + ); + + it("should preserve unknown properties", () => { + const policy = Object.assign({}, DisabledPasswordGeneratorPolicy); + const builder = new PasswordGeneratorOptionsEvaluator(policy); + const options = Object.freeze({ + unknown: "property", + another: "unknown property", + }) as PasswordGenerationOptions; + + const sanitizedOptions: any = builder.sanitize(options); + + expect(sanitizedOptions.unknown).toEqual("property"); + expect(sanitizedOptions.another).toEqual("unknown property"); + }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts new file mode 100644 index 0000000000..e0cc8f25d3 --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-generator-options-evaluator.ts @@ -0,0 +1,157 @@ +import { PolicyEvaluator } from "../abstractions"; +import { DefaultPasswordBoundaries } from "../data"; +import { Boundary, PasswordGeneratorPolicy, PasswordGenerationOptions } from "../types"; + +/** Enforces policy for password generation. + */ +export class PasswordGeneratorOptionsEvaluator + implements PolicyEvaluator +{ + // This design is not ideal, but it is a step towards a more robust password + // generator. Ideally, `sanitize` would be implemented on an options class, + // and `applyPolicy` would be implemented on a policy class, "mise en place". + // + // The current design of the password generator, unfortunately, would require + // a substantial rewrite to make this feasible. Hopefully this change can be + // applied when the password generator is ported to rust. + + /** Boundaries for the password length. This is always large enough + * to accommodate the minimum number of digits and special characters. + */ + readonly length: Boundary; + + /** Boundaries for the minimum number of digits allowed in the password. + */ + readonly minDigits: Boundary; + + /** Boundaries for the minimum number of special characters allowed + * in the password. + */ + readonly minSpecialCharacters: Boundary; + + /** Policy applied by the evaluator. + */ + readonly policy: PasswordGeneratorPolicy; + + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(policy: PasswordGeneratorPolicy) { + function createBoundary(value: number, defaultBoundary: Boundary): Boundary { + const boundary = { + min: Math.max(defaultBoundary.min, value), + max: Math.max(defaultBoundary.max, value), + }; + + return boundary; + } + + this.policy = structuredClone(policy); + this.minDigits = createBoundary(policy.numberCount, DefaultPasswordBoundaries.minDigits); + this.minSpecialCharacters = createBoundary( + policy.specialCount, + DefaultPasswordBoundaries.minSpecialCharacters, + ); + + // the overall length should be at least as long as the sum of the minimums + const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min; + const minPolicyLength = + policy.minLength > 0 ? policy.minLength : DefaultPasswordBoundaries.length.min; + const minLength = Math.max( + minPolicyLength, + minConsistentLength, + DefaultPasswordBoundaries.length.min, + ); + + this.length = { + min: minLength, + max: Math.max(DefaultPasswordBoundaries.length.max, minLength), + }; + } + + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + const policies = [ + this.policy.useUppercase, + this.policy.useLowercase, + this.policy.useNumbers, + this.policy.useSpecial, + this.policy.minLength > DefaultPasswordBoundaries.length.min, + this.policy.numberCount > DefaultPasswordBoundaries.minDigits.min, + this.policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min, + ]; + + return policies.includes(true); + } + + /** {@link PolicyEvaluator.applyPolicy} */ + applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions { + function fitToBounds(value: number, boundaries: Boundary) { + const { min, max } = boundaries; + + const withUpperBound = Math.min(value || 0, max); + const withLowerBound = Math.max(withUpperBound, min); + + return withLowerBound; + } + + // apply policy overrides + const uppercase = this.policy.useUppercase || options.uppercase || false; + const lowercase = this.policy.useLowercase || options.lowercase || false; + + // these overrides can cascade numeric fields to boolean fields + const number = this.policy.useNumbers || options.number || options.minNumber > 0; + const special = this.policy.useSpecial || options.special || options.minSpecial > 0; + + // apply boundaries; the boundaries can cascade boolean fields to numeric fields + const length = fitToBounds(options.length, this.length); + const minNumber = fitToBounds(options.minNumber, this.minDigits); + const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters); + + return { + ...options, + length, + uppercase, + lowercase, + number, + minNumber, + special, + minSpecial, + }; + } + + /** {@link PolicyEvaluator.sanitize} */ + sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions { + function cascade(enabled: boolean, value: number): [boolean, number] { + const enabledResult = enabled ?? value > 0; + const valueResult = enabledResult ? value || 1 : 0; + + return [enabledResult, valueResult]; + } + + const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase); + const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase); + const [number, minNumber] = cascade(options.number, options.minNumber); + const [special, minSpecial] = cascade(options.special, options.minSpecial); + + // minimums can only increase the length + const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial; + const minLength = Math.max(minConsistentLength, this.length.min); + const length = Math.max(options.length ?? minLength, minLength); + + return { + ...options, + length, + minLength, + lowercase, + minLowercase, + uppercase, + minUppercase, + number, + minNumber, + special, + minSpecial, + }; + } +} diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts new file mode 100644 index 0000000000..2ce02a97a2 --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-least-privilege.spec.ts @@ -0,0 +1,57 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { PolicyId } from "@bitwarden/common/types/guid"; + +import { DisabledPasswordGeneratorPolicy } from "../data"; + +import { passwordLeastPrivilege } from "./password-least-privilege"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("passwordLeastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual(DisabledPasswordGeneratorPolicy); + }); + + it.each([ + ["minLength", 10, "minLength"], + ["useUpper", true, "useUppercase"], + ["useLower", true, "useLowercase"], + ["useNumbers", true, "useNumbers"], + ["minNumbers", 10, "numberCount"], + ["useSpecial", true, "useSpecial"], + ["minSpecial", 10, "specialCount"], + ])("should take the %p from the policy", (input, value, expected) => { + const policy = createPolicy({ [input]: value }); + + const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy); + + expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value }); + }); +}); diff --git a/libs/tools/generator/core/src/policies/password-least-privilege.ts b/libs/tools/generator/core/src/policies/password-least-privilege.ts new file mode 100644 index 0000000000..ffc6a811ae --- /dev/null +++ b/libs/tools/generator/core/src/policies/password-least-privilege.ts @@ -0,0 +1,28 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; + +import { PasswordGeneratorPolicy } from "../types"; + +/** Reduces a policy into an accumulator by accepting the most restrictive + * values from each policy. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the most restrictive values between the policy and accumulator. + */ +export function passwordLeastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) { + if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) { + return acc; + } + + return { + minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength), + useUppercase: policy.data.useUpper || acc.useUppercase, + useLowercase: policy.data.useLower || acc.useLowercase, + useNumbers: policy.data.useNumbers || acc.useNumbers, + numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount), + useSpecial: policy.data.useSpecial || acc.useSpecial, + specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount), + }; +} diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts new file mode 100644 index 0000000000..ab907b6455 --- /dev/null +++ b/libs/tools/generator/core/src/rx.ts @@ -0,0 +1,26 @@ +import { map, pipe } from "rxjs"; + +import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; + +import { DefaultPolicyEvaluator } from "./policies"; +import { PolicyConfiguration } from "./types"; + +/** Maps an administrative console policy to a policy evaluator using the provided configuration. + * @param configuration the configuration that constructs the evaluator. + */ +export function mapPolicyToEvaluator( + configuration: PolicyConfiguration, +) { + return pipe( + reduceCollection(configuration.combine, configuration.disabledValue), + distinctIfShallowMatch(), + map(configuration.createEvaluator), + ); +} + +/** Constructs a method that maps a policy to the default (no-op) policy. */ +export function newDefaultEvaluator() { + return () => { + return pipe(map((_) => new DefaultPolicyEvaluator())); + }; +} diff --git a/libs/tools/generator/core/src/services/default-generator.service.spec.ts b/libs/tools/generator/core/src/services/default-generator.service.spec.ts new file mode 100644 index 0000000000..4bef94108f --- /dev/null +++ b/libs/tools/generator/core/src/services/default-generator.service.spec.ts @@ -0,0 +1,194 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { SingleUserState } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeSingleUserState, awaitAsync } from "../../../../../common/spec"; +import { GeneratorStrategy, PolicyEvaluator } from "../abstractions"; +import { PasswordGenerationOptions } from "../types"; + +import { DefaultGeneratorService } from "./default-generator.service"; + +function mockPolicyService(config?: { state?: BehaviorSubject }) { + const service = mock(); + + const stateValue = config?.state ?? new BehaviorSubject([null]); + service.getAll$.mockReturnValue(stateValue); + + return service; +} + +function mockGeneratorStrategy(config?: { + userState?: SingleUserState; + policy?: PolicyType; + evaluator?: any; + defaults?: any; +}) { + const durableState = + config?.userState ?? new FakeSingleUserState(SomeUser); + const strategy = mock>({ + // intentionally arbitrary so that tests that need to check + // whether they're used properly are guaranteed to test + // the value from `config`. + durableState: jest.fn(() => durableState), + defaults$: jest.fn(() => new BehaviorSubject(config?.defaults)), + policy: config?.policy ?? PolicyType.DisableSend, + toEvaluator: jest.fn(() => + pipe(map(() => config?.evaluator ?? mock>())), + ), + }); + + return strategy; +} + +const SomeUser = "some user" as UserId; +const AnotherUser = "another user" as UserId; + +describe("Password generator service", () => { + describe("options$", () => { + it("should retrieve durable state from the service", () => { + const policy = mockPolicyService(); + const userState = new FakeSingleUserState(SomeUser); + const strategy = mockGeneratorStrategy({ userState }); + const service = new DefaultGeneratorService(strategy, policy); + + const result = service.options$(SomeUser); + + expect(strategy.durableState).toHaveBeenCalledWith(SomeUser); + expect(result).toBe(userState.state$); + }); + }); + + describe("defaults$", () => { + it("should retrieve default state from the service", async () => { + const policy = mockPolicyService(); + const defaults = {}; + const strategy = mockGeneratorStrategy({ defaults }); + const service = new DefaultGeneratorService(strategy, policy); + + const result = await firstValueFrom(service.defaults$(SomeUser)); + + expect(strategy.defaults$).toHaveBeenCalledWith(SomeUser); + expect(result).toBe(defaults); + }); + }); + + describe("saveOptions()", () => { + it("should trigger an options$ update", async () => { + const policy = mockPolicyService(); + const userState = new FakeSingleUserState(SomeUser, { length: 9 }); + const strategy = mockGeneratorStrategy({ userState }); + const service = new DefaultGeneratorService(strategy, policy); + + await service.saveOptions(SomeUser, { length: 10 }); + await awaitAsync(); + const options = await firstValueFrom(service.options$(SomeUser)); + + expect(strategy.durableState).toHaveBeenCalledWith(SomeUser); + expect(options).toEqual({ length: 10 }); + }); + }); + + describe("evaluator$", () => { + it("should initialize the password generator policy", async () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); + const service = new DefaultGeneratorService(strategy, policy); + + await firstValueFrom(service.evaluator$(SomeUser)); + + expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); + }); + + it("should map the policy using the generation strategy", async () => { + const policyService = mockPolicyService(); + const evaluator = mock>(); + const strategy = mockGeneratorStrategy({ evaluator }); + const service = new DefaultGeneratorService(strategy, policyService); + + const policy = await firstValueFrom(service.evaluator$(SomeUser)); + + expect(policy).toBe(evaluator); + }); + + it("should update the evaluator when the password generator policy changes", async () => { + // set up dependencies + const state = new BehaviorSubject([null]); + const policy = mockPolicyService({ state }); + const strategy = mockGeneratorStrategy(); + const service = new DefaultGeneratorService(strategy, policy); + + // model responses for the observable update. The map is called multiple times, + // and the array shift ensures reference equality is maintained. + const firstEvaluator = mock>(); + const secondEvaluator = mock>(); + const evaluators = [firstEvaluator, secondEvaluator]; + strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift()))); + + // act + const evaluator$ = service.evaluator$(SomeUser); + const firstResult = await firstValueFrom(evaluator$); + state.next([null]); + const secondResult = await firstValueFrom(evaluator$); + + // assert + expect(firstResult).toBe(firstEvaluator); + expect(secondResult).toBe(secondEvaluator); + }); + + it("should cache the password generator policy", async () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); + const service = new DefaultGeneratorService(strategy, policy); + + await firstValueFrom(service.evaluator$(SomeUser)); + await firstValueFrom(service.evaluator$(SomeUser)); + + expect(policy.getAll$).toHaveBeenCalledTimes(1); + }); + + it("should cache the password generator policy for each user", async () => { + const policy = mockPolicyService(); + const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator }); + const service = new DefaultGeneratorService(strategy, policy); + + await firstValueFrom(service.evaluator$(SomeUser)); + await firstValueFrom(service.evaluator$(AnotherUser)); + + expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser); + expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser); + }); + }); + + describe("enforcePolicy()", () => { + it("should evaluate the policy using the generation strategy", async () => { + const policy = mockPolicyService(); + const evaluator = mock>(); + const strategy = mockGeneratorStrategy({ evaluator }); + const service = new DefaultGeneratorService(strategy, policy); + + await service.enforcePolicy(SomeUser, {}); + + expect(evaluator.applyPolicy).toHaveBeenCalled(); + expect(evaluator.sanitize).toHaveBeenCalled(); + }); + }); + + describe("generate()", () => { + it("should invoke the generation strategy", async () => { + const strategy = mockGeneratorStrategy(); + const policy = mockPolicyService(); + const service = new DefaultGeneratorService(strategy, policy); + + await service.generate({}); + + expect(strategy.generate).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/tools/generator/core/src/services/default-generator.service.ts b/libs/tools/generator/core/src/services/default-generator.service.ts new file mode 100644 index 0000000000..a577acd734 --- /dev/null +++ b/libs/tools/generator/core/src/services/default-generator.service.ts @@ -0,0 +1,96 @@ +import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs"; + +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "../abstractions"; + +type DefaultGeneratorServiceTuning = { + /* amount of time to keep the most recent policy after a subscription ends. Once the + * cache expires, the ignoreQty and timeoutMs settings apply to the next lookup. + */ + policyCacheMs: number; +}; + +/** {@link GeneratorServiceAbstraction} */ +export class DefaultGeneratorService implements GeneratorService { + /** Instantiates the generator service + * @param strategy tailors the service to a specific generator type + * (e.g. password, passphrase) + * @param policy provides the policy to enforce + */ + constructor( + private strategy: GeneratorStrategy, + private policy: PolicyService, + tuning: Partial = {}, + ) { + this.tuning = Object.assign( + { + // a minute + policyCacheMs: 60000, + }, + tuning, + ); + } + + private tuning: DefaultGeneratorServiceTuning; + private _evaluators$ = new Map>>(); + + /** {@link GeneratorService.options$} */ + options$(userId: UserId) { + return this.strategy.durableState(userId).state$; + } + + /** {@link GeneratorService.defaults$} */ + defaults$(userId: UserId) { + return this.strategy.defaults$(userId); + } + + /** {@link GeneratorService.saveOptions} */ + async saveOptions(userId: UserId, options: Options): Promise { + await this.strategy.durableState(userId).update(() => options); + } + + /** {@link GeneratorService.evaluator$} */ + evaluator$(userId: UserId) { + let evaluator$ = this._evaluators$.get(userId); + + if (!evaluator$) { + evaluator$ = this.createEvaluator(userId); + this._evaluators$.set(userId, evaluator$); + } + + return evaluator$; + } + + private createEvaluator(userId: UserId) { + const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe( + // create the evaluator from the policies + this.strategy.toEvaluator(), + + // cache evaluator in a replay subject to amortize creation cost + // and reduce GC pressure. + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: () => timer(this.tuning.policyCacheMs), + }), + ); + + return evaluator$; + } + + /** {@link GeneratorService.enforcePolicy} */ + async enforcePolicy(userId: UserId, options: Options): Promise { + const policy = await firstValueFrom(this.evaluator$(userId)); + const evaluated = policy.applyPolicy(options); + const sanitized = policy.sanitize(evaluated); + return sanitized; + } + + /** {@link GeneratorService.generate} */ + async generate(options: Options): Promise { + return await this.strategy.generate(options); + } +} diff --git a/libs/tools/generator/core/src/services/index.ts b/libs/tools/generator/core/src/services/index.ts new file mode 100644 index 0000000000..7568f55b68 --- /dev/null +++ b/libs/tools/generator/core/src/services/index.ts @@ -0,0 +1 @@ +export { DefaultGeneratorService } from "./default-generator.service"; diff --git a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts new file mode 100644 index 0000000000..dcb7227b1c --- /dev/null +++ b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.spec.ts @@ -0,0 +1,75 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Randomizer } from "../abstractions"; +import { DefaultCatchallOptions } from "../data"; +import { DefaultPolicyEvaluator } from "../policies"; + +import { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; +import { CATCHALL_SETTINGS } from "./storage"; + +const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); + +describe("Email subaddress list generation strategy", () => { + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new CatchallGeneratorStrategy(null, null); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); + }); + + describe("durableState", () => { + it("should use password settings key", () => { + const provider = mock(); + const randomizer = mock(); + const strategy = new CatchallGeneratorStrategy(randomizer, provider); + + strategy.durableState(SomeUser); + + expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS); + }); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new CatchallGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultCatchallOptions); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const randomizer = mock(); + const strategy = new CatchallGeneratorStrategy(randomizer, null); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it.todo("generate catchall email addresses"); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts new file mode 100644 index 0000000000..af7e2b61f4 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/catchall-generator-strategy.ts @@ -0,0 +1,50 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { DefaultCatchallOptions } from "../data"; +import { newDefaultEvaluator } from "../rx"; +import { NoPolicy, CatchallGenerationOptions } from "../types"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; + +import { CATCHALL_SETTINGS } from "./storage"; + +/** Strategy for creating usernames using a catchall email address */ +export class CatchallGeneratorStrategy + implements GeneratorStrategy +{ + /** Instantiates the generation strategy + * @param usernameService generates a catchall address for a domain + */ + constructor( + private random: Randomizer, + private stateProvider: StateProvider, + private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions, + ) {} + + // configuration + durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; + + // algorithm + async generate(options: CatchallGenerationOptions) { + const o = Object.assign({}, DefaultCatchallOptions, options); + + if (o.catchallDomain == null || o.catchallDomain === "") { + return null; + } + if (o.catchallType == null) { + o.catchallType = "random"; + } + + let startString = ""; + if (o.catchallType === "random") { + startString = await this.random.chars(8); + } else if (o.catchallType === "website-name") { + startString = o.website; + } + return startString + "@" + o.catchallDomain; + } +} diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts new file mode 100644 index 0000000000..8583664731 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.spec.ts @@ -0,0 +1,75 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Randomizer } from "../abstractions"; +import { DefaultEffUsernameOptions } from "../data"; +import { DefaultPolicyEvaluator } from "../policies"; + +import { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy"; +import { EFF_USERNAME_SETTINGS } from "./storage"; + +const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); + +describe("EFF long word list generation strategy", () => { + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new EffUsernameGeneratorStrategy(null, null); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); + }); + + describe("durableState", () => { + it("should use password settings key", () => { + const provider = mock(); + const randomizer = mock(); + const strategy = new EffUsernameGeneratorStrategy(randomizer, provider); + + strategy.durableState(SomeUser); + + expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS); + }); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new EffUsernameGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultEffUsernameOptions); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const randomizer = mock(); + const strategy = new EffUsernameGeneratorStrategy(randomizer, null); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it.todo("generate username tests"); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts new file mode 100644 index 0000000000..bcedfb60a7 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/eff-username-generator-strategy.ts @@ -0,0 +1,40 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { DefaultEffUsernameOptions } from "../data"; +import { newDefaultEvaluator } from "../rx"; +import { EffUsernameGenerationOptions, NoPolicy } from "../types"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; + +import { EFF_USERNAME_SETTINGS } from "./storage"; + +/** Strategy for creating usernames from the EFF wordlist */ +export class EffUsernameGeneratorStrategy + implements GeneratorStrategy +{ + /** Instantiates the generation strategy + * @param usernameService generates a username from EFF word list + */ + constructor( + private random: Randomizer, + private stateProvider: StateProvider, + private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions, + ) {} + + // configuration + durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; + + // algorithm + async generate(options: EffUsernameGenerationOptions) { + const word = await this.random.pickWord(EFFLongWordList, { + titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize, + number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber, + }); + return word; + } +} diff --git a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts new file mode 100644 index 0000000000..31c3145559 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts @@ -0,0 +1,110 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec"; +import { DefaultDuckDuckGoOptions } from "../data"; +import { DefaultPolicyEvaluator } from "../policies"; +import { ApiOptions } from "../types"; + +import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "./storage"; + +class TestForwarder extends ForwarderGeneratorStrategy { + constructor( + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, { website: null, token: "" }); + } + + get key() { + // arbitrary. + return DUCK_DUCK_GO_FORWARDER; + } + + get rolloverKey() { + return DUCK_DUCK_GO_BUFFER; + } + + defaults$ = (userId: UserId) => { + return of(DefaultDuckDuckGoOptions); + }; +} + +const SomeUser = "some user" as UserId; +const AnotherUser = "another user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); + +describe("ForwarderGeneratorStrategy", () => { + const encryptService = mock(); + const keyService = mock(); + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + + beforeEach(() => { + const keyAvailable = of({} as UserKey); + keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("durableState", () => { + it("constructs a secret state", () => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); + + const result = strategy.durableState(SomeUser); + + expect(result).toBeInstanceOf(BufferedState); + }); + + it("returns the same secret state for a single user", () => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); + + const firstResult = strategy.durableState(SomeUser); + const secondResult = strategy.durableState(SomeUser); + + expect(firstResult).toBe(secondResult); + }); + + it("returns a different secret state for a different user", () => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); + + const firstResult = strategy.durableState(SomeUser); + const secondResult = strategy.durableState(AnotherUser); + + expect(firstResult).not.toBe(secondResult); + }); + }); + + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new TestForwarder(encryptService, keyService, stateProvider); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts new file mode 100644 index 0000000000..4dbabac1c7 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts @@ -0,0 +1,95 @@ +import { map } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { + SingleUserState, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; +import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; +import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier"; +import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition"; +import { SecretState } from "@bitwarden/common/tools/state/secret-state"; +import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { GeneratorStrategy } from "../abstractions"; +import { newDefaultEvaluator } from "../rx"; +import { ApiOptions, NoPolicy } from "../types"; +import { clone$PerUserId, sharedByUserId } from "../util"; + +const OPTIONS_FRAME_SIZE = 512; + +/** An email forwarding service configurable through an API. */ +export abstract class ForwarderGeneratorStrategy< + Options extends ApiOptions, +> extends GeneratorStrategy { + /** Initializes the generator strategy + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: CryptoService, + private stateProvider: StateProvider, + private readonly defaultOptions: Options, + ) { + super(); + } + + /** configures forwarder secret storage */ + protected abstract readonly key: UserKeyDefinition; + + /** configures forwarder import buffer */ + protected abstract readonly rolloverKey: BufferedKeyDefinition; + + // configuration + readonly policy = PolicyType.PasswordGenerator; + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + durableState = sharedByUserId((userId) => this.getUserSecrets(userId)); + + // per-user encrypted state + private getUserSecrets(userId: UserId): SingleUserState { + // construct the encryptor + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + // always exclude request properties + const classifier = SecretClassifier.allSecret().exclude("website"); + + // Derive the secret key definition + const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { + deserializer: (d) => this.key.deserializer(d), + cleanupDelayMs: this.key.cleanupDelayMs, + clearOn: this.key.clearOn, + }); + + // the type parameter is explicit because type inference fails for `Omit` + const secretState = SecretState.from< + Options, + void, + Options, + Record, + Omit + >(userId, key, this.stateProvider, encryptor); + + // rollover should occur once the user key is available for decryption + const canDecrypt$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key !== null)); + const rolloverState = new BufferedState( + this.stateProvider, + this.rolloverKey, + secretState, + canDecrypt$, + ); + + return rolloverState; + } +} diff --git a/libs/tools/generator/core/src/strategies/forwarders/addy-io.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/addy-io.spec.ts new file mode 100644 index 0000000000..438ae4bffd --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/addy-io.spec.ts @@ -0,0 +1,233 @@ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultAddyIoOptions } from "../../data"; +import { ADDY_IO_FORWARDER } from "../storage"; + +import { AddyIoForwarder } from "./addy-io"; +import { mockApiService, mockI18nService } from "./mocks.jest"; + +const SomeUser = "some user" as UserId; + +describe("Addy.io Forwarder", () => { + it("key returns the Addy IO forwarder key", () => { + const forwarder = new AddyIoForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(ADDY_IO_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new AddyIoForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultAddyIoOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name); + }); + + it.each([null, ""])( + "throws an error if the domain is missing (domain = %p)", + async (domain) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain, + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderNoDomain"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name); + }, + ); + + it.each([null, ""])( + "throws an error if the baseUrl is missing (baseUrl = %p)", + async (baseUrl) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + baseUrl, + }), + ).rejects.toEqual("forwarderNoUrl"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name); + }, + ); + + it.each([ + ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], + ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], + ["forwarderGeneratedBy", "not provided", null, ""], + ["forwarderGeneratedBy", "not provided", "", ""], + ])( + "describes the website with %p when the website is %s (= %p)", + async (translationKey, _ignored, website, expectedWebsite) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await forwarder.generate({ + website, + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }); + + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); + }, + ); + + it.each([ + ["jane.doe@example.com", 201], + ["john.doe@example.com", 201], + ["jane.doe@example.com", 200], + ["john.doe@example.com", 200], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (email, status) => { + const apiService = mockApiService(status, { data: { email } }); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }); + + expect(result).toEqual(email); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidToken", + Forwarders.AddyIo.name, + ); + }); + + it("throws an unknown error if the request fails and no status is provided", async () => { + const apiService = mockApiService(500, {}); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderUnknownError", + Forwarders.AddyIo.name, + ); + }); + + it.each([ + [100, "Continue"], + [202, "Accepted"], + [300, "Multiple Choices"], + [418, "I'm a teapot"], + [500, "Internal Server Error"], + [600, "Unknown Status"], + ])( + "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", + async (statusCode, statusText) => { + const apiService = mockApiService(statusCode, {}, statusText); + const i18nService = mockI18nService(); + + const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderError", + Forwarders.AddyIo.name, + statusText, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/addy-io.ts b/libs/tools/generator/core/src/strategies/forwarders/addy-io.ts new file mode 100644 index 0000000000..33ffb626d4 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/addy-io.ts @@ -0,0 +1,100 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { DefaultAddyIoOptions, Forwarders } from "../../data"; +import { EmailDomainOptions, SelfHostedApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../storage"; + +/** Generates a forwarding address for addy.io (formerly anon addy) */ +export class AddyIoForwarder extends ForwarderGeneratorStrategy< + SelfHostedApiOptions & EmailDomainOptions +> { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultAddyIoOptions); + } + + // configuration + readonly key = ADDY_IO_FORWARDER; + readonly rolloverKey = ADDY_IO_BUFFER; + + // request + generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); + throw error; + } + if (!options.domain || options.domain === "") { + const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name); + throw error; + } + if (!options.baseUrl || options.baseUrl === "") { + const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name); + throw error; + } + + let descriptionId = "forwarderGeneratedByWithWebsite"; + if (!options.website || options.website === "") { + descriptionId = "forwarderGeneratedBy"; + } + const description = this.i18nService.t(descriptionId, options.website ?? ""); + + const url = options.baseUrl + "/api/v1/aliases"; + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Bearer " + options.token, + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest", + }), + body: JSON.stringify({ + domain: options.domain, + description, + }), + }); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 200 || response.status === 201) { + const json = await response.json(); + return json?.data?.email; + } else if (response.status === 401) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name); + throw error; + } else if (response?.statusText) { + const error = this.i18nService.t( + "forwarderError", + Forwarders.AddyIo.name, + response.statusText, + ); + throw error; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name); + throw error; + } + }; +} + +export const DefaultOptions = Object.freeze({ + website: null, + baseUrl: "https://app.addy.io", + domain: "", + token: "", +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.spec.ts new file mode 100644 index 0000000000..4c4566e6ec --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.spec.ts @@ -0,0 +1,144 @@ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultDuckDuckGoOptions } from "../../data"; +import { DUCK_DUCK_GO_FORWARDER } from "../storage"; + +import { DuckDuckGoForwarder } from "./duck-duck-go"; +import { mockApiService, mockI18nService } from "./mocks.jest"; + +const SomeUser = "some user" as UserId; + +describe("DuckDuckGo Forwarder", () => { + it("key returns the Duck Duck Go forwarder key", () => { + const forwarder = new DuckDuckGoForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new DuckDuckGoForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultDuckDuckGoOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.DuckDuckGo.name, + ); + }); + + it.each([ + ["jane.doe@duck.com", 201, "jane.doe"], + ["john.doe@duck.com", 201, "john.doe"], + ["jane.doe@duck.com", 200, "jane.doe"], + ["john.doe@duck.com", 200, "john.doe"], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (email, status, address) => { + const apiService = mockApiService(status, { address }); + const i18nService = mockI18nService(); + + const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + }); + + expect(result).toEqual(email); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.DuckDuckGo.name, + ); + }); + + it("throws an unknown error if the request is successful but an address isn't present", async () => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderUnknownError", + Forwarders.DuckDuckGo.name, + ); + }); + + it.each([100, 202, 300, 418, 500, 600])( + "throws an unknown error if the request returns any other status code (= %i)", + async (statusCode) => { + const apiService = mockApiService(statusCode, {}); + const i18nService = mockI18nService(); + + const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderUnknownError", + Forwarders.DuckDuckGo.name, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.ts b/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.ts new file mode 100644 index 0000000000..cf1b26508c --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/duck-duck-go.ts @@ -0,0 +1,75 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { Forwarders, DefaultDuckDuckGoOptions } from "../../data"; +import { ApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../storage"; + +/** Generates a forwarding address for DuckDuckGo */ +export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions); + } + + // configuration + readonly key = DUCK_DUCK_GO_FORWARDER; + readonly rolloverKey = DUCK_DUCK_GO_BUFFER; + + // request + generate = async (options: ApiOptions): Promise => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); + throw error; + } + + const url = "https://quack.duckduckgo.com/api/email/addresses"; + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Bearer " + options.token, + "Content-Type": "application/json", + }), + }); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 200 || response.status === 201) { + const json = await response.json(); + if (json.address) { + return `${json.address}@duck.com`; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name); + throw error; + } + } else if (response.status === 401) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name); + throw error; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name); + throw error; + } + }; +} + +export const DefaultOptions = Object.freeze({ + website: null, + token: "", +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/fastmail.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/fastmail.spec.ts new file mode 100644 index 0000000000..6920f69647 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/fastmail.spec.ts @@ -0,0 +1,281 @@ +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultFastmailOptions } from "../../data"; +import { FASTMAIL_FORWARDER } from "../storage"; + +import { FastmailForwarder } from "./fastmail"; +import { mockI18nService } from "./mocks.jest"; + +const SomeUser = "some user" as UserId; + +type MockResponse = { status: number; body: any }; + +// fastmail calls nativeFetch first to resolve the accountId, +// then it calls nativeFetch again to create the forwarding address. +// The common mock doesn't work here, because this test needs to return multiple responses +function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) { + function response(r: MockResponse) { + return { + status: r.status, + json: jest.fn().mockImplementation(() => Promise.resolve(r.body)), + }; + } + + return { + nativeFetch: jest + .fn() + .mockImplementationOnce((r: Request) => response(accountId)) + .mockImplementationOnce((r: Request) => response(forwardingAddress)), + } as unknown as ApiService; +} + +const EmptyResponse: MockResponse = Object.freeze({ + status: 200, + body: Object.freeze({}), +}); + +const AccountIdSuccess: MockResponse = Object.freeze({ + status: 200, + body: Object.freeze({ + primaryAccounts: Object.freeze({ + "https://www.fastmail.com/dev/maskedemail": "accountId", + }), + }), +}); + +// the tests +describe("Fastmail Forwarder", () => { + it("key returns the Fastmail forwarder key", () => { + const forwarder = new FastmailForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(FASTMAIL_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new FastmailForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultFastmailOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(AccountIdSuccess, EmptyResponse); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name); + }); + + it.each([401, 403])( + "throws a no account id error if the accountId request responds with a status other than 200", + async (status) => { + const apiService = mockApiService({ status, body: {} }, EmptyResponse); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwarderNoAccountId"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderNoAccountId", + Forwarders.Fastmail.name, + ); + }, + ); + + it.each([ + ["jane.doe@example.com", 200], + ["john.doe@example.com", 200], + ])( + "returns the generated email address (= %p) if both requests are successful (status = %p)", + async (email, status) => { + const apiService = mockApiService(AccountIdSuccess, { + status, + body: { + methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]], + }, + }); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }); + + expect(result).toEqual(email); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it.each([ + [ + "It turned inside out!", + [ + "MaskedEmail/set", + { notCreated: { "new-masked-email": { description: "It turned inside out!" } } }, + ], + ], + ["And then it exploded!", ["error", { description: "And then it exploded!" }]], + ])( + "throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails", + async (description, response) => { + const apiService = mockApiService(AccountIdSuccess, { + status: 200, + body: { + methodResponses: [response], + }, + }); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderError", + Forwarders.Fastmail.name, + description, + ); + }, + ); + + it.each([401, 403])( + "throws an invalid token error if the jmap request fails with a %i", + async (status) => { + const apiService = mockApiService(AccountIdSuccess, { status, body: {} }); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.Fastmail.name, + ); + }, + ); + + it.each([ + null, + [], + [[]], + [["MaskedEmail/not-a-real-op"]], + [["MaskedEmail/set", null]], + [["MaskedEmail/set", { created: null }]], + [["MaskedEmail/set", { created: { "new-masked-email": null } }]], + [["MaskedEmail/set", { notCreated: null }]], + [["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]], + ])( + "throws an unknown error if the jmap request is malformed (= %p)", + async (responses: any) => { + const apiService = mockApiService(AccountIdSuccess, { + status: 200, + body: { + methodResponses: responses, + }, + }); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderUnknownError", + Forwarders.Fastmail.name, + ); + }, + ); + + it.each([100, 202, 300, 418, 500, 600])( + "throws an unknown error if the request returns any other status code (= %i)", + async (statusCode) => { + const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} }); + const i18nService = mockI18nService(); + + const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + prefix: "prefix", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderUnknownError", + Forwarders.Fastmail.name, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/fastmail.ts b/libs/tools/generator/core/src/strategies/forwarders/fastmail.ts new file mode 100644 index 0000000000..283abc3e88 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/fastmail.ts @@ -0,0 +1,150 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { Forwarders, DefaultFastmailOptions } from "../../data"; +import { EmailPrefixOptions, ApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../storage"; + +/** Generates a forwarding address for Fastmail */ +export class FastmailForwarder extends ForwarderGeneratorStrategy { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultFastmailOptions); + } + + // configuration + readonly key = FASTMAIL_FORWARDER; + readonly rolloverKey = FASTMAIL_BUFFER; + + // request + generate = async (options: ApiOptions & EmailPrefixOptions) => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); + throw error; + } + + const accountId = await this.getAccountId(options); + if (!accountId || accountId === "") { + const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name); + throw error; + } + + const body = JSON.stringify({ + using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"], + methodCalls: [ + [ + "MaskedEmail/set", + { + accountId: accountId, + create: { + "new-masked-email": { + state: "enabled", + description: "", + forDomain: options.website ?? "", + emailPrefix: options.prefix, + }, + }, + }, + "0", + ], + ], + }); + + const requestInit: RequestInit = { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Bearer " + options.token, + "Content-Type": "application/json", + }), + body, + }; + + const url = "https://api.fastmail.com/jmap/api/"; + const request = new Request(url, requestInit); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 200) { + const json = await response.json(); + if ( + json.methodResponses != null && + json.methodResponses.length > 0 && + json.methodResponses[0].length > 0 + ) { + if (json.methodResponses[0][0] === "MaskedEmail/set") { + if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) { + return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email; + } + if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) { + const errorDescription = + json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description; + const error = this.i18nService.t( + "forwarderError", + Forwarders.Fastmail.name, + errorDescription, + ); + throw error; + } + } else if (json.methodResponses[0][0] === "error") { + const errorDescription = json.methodResponses[0][1]?.description; + const error = this.i18nService.t( + "forwarderError", + Forwarders.Fastmail.name, + errorDescription, + ); + throw error; + } + } + } else if (response.status === 401 || response.status === 403) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name); + throw error; + } + + const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name); + throw error; + }; + + private async getAccountId(options: ApiOptions): Promise { + const requestInit: RequestInit = { + cache: "no-store", + method: "GET", + headers: new Headers({ + Authorization: "Bearer " + options.token, + }), + }; + const url = "https://api.fastmail.com/.well-known/jmap"; + const request = new Request(url, requestInit); + const response = await this.apiService.nativeFetch(request); + if (response.status === 200) { + const json = await response.json(); + if (json.primaryAccounts != null) { + return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"]; + } + } + return null; + } +} + +export const DefaultOptions = Object.freeze({ + website: null, + domain: "", + prefix: "", + token: "", +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.spec.ts new file mode 100644 index 0000000000..603694bdd8 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.spec.ts @@ -0,0 +1,147 @@ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultFirefoxRelayOptions } from "../../data"; +import { FIREFOX_RELAY_FORWARDER } from "../storage"; + +import { FirefoxRelayForwarder } from "./firefox-relay"; +import { mockApiService, mockI18nService } from "./mocks.jest"; + +const SomeUser = "some user" as UserId; + +describe("Firefox Relay Forwarder", () => { + it("key returns the Firefox Relay forwarder key", () => { + const forwarder = new FirefoxRelayForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new FirefoxRelayForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultFirefoxRelayOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.FirefoxRelay.name, + ); + }); + + it.each([ + ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], + ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], + ["forwarderGeneratedBy", "not provided", null, ""], + ["forwarderGeneratedBy", "not provided", "", ""], + ])( + "describes the website with %p when the website is %s (= %p)", + async (translationKey, _ignored, website, expectedWebsite) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); + + await forwarder.generate({ + website, + token: "token", + }); + + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); + }, + ); + + it.each([ + ["jane.doe@duck.com", 201], + ["john.doe@duck.com", 201], + ["jane.doe@duck.com", 200], + ["john.doe@duck.com", 200], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (full_address, status) => { + const apiService = mockApiService(status, { full_address }); + const i18nService = mockI18nService(); + + const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + }); + + expect(result).toEqual(full_address); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidToken", + Forwarders.FirefoxRelay.name, + ); + }); + + it.each([100, 202, 300, 418, 500, 600])( + "throws an unknown error if the request returns any other status code (= %i)", + async (statusCode) => { + const apiService = mockApiService(statusCode, {}); + const i18nService = mockI18nService(); + + const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderUnknownError", + Forwarders.FirefoxRelay.name, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.ts b/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.ts new file mode 100644 index 0000000000..b7e7ea8785 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/firefox-relay.ts @@ -0,0 +1,82 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { Forwarders, DefaultFirefoxRelayOptions } from "../../data"; +import { ApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../storage"; + +/** Generates a forwarding address for Firefox Relay */ +export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions); + } + + // configuration + readonly key = FIREFOX_RELAY_FORWARDER; + readonly rolloverKey = FIREFOX_RELAY_BUFFER; + + // request + generate = async (options: ApiOptions) => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); + throw error; + } + + const url = "https://relay.firefox.com/api/v1/relayaddresses/"; + + let descriptionId = "forwarderGeneratedByWithWebsite"; + if (!options.website || options.website === "") { + descriptionId = "forwarderGeneratedBy"; + } + const description = this.i18nService.t(descriptionId, options.website ?? ""); + + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Token " + options.token, + "Content-Type": "application/json", + }), + body: JSON.stringify({ + enabled: true, + generated_for: options.website, + description, + }), + }); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 401) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name); + throw error; + } else if (response.status === 200 || response.status === 201) { + const json = await response.json(); + return json.full_address; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name); + throw error; + } + }; +} + +export const DefaultOptions = Object.freeze({ + website: null, + token: "", +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/forward-email.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/forward-email.spec.ts new file mode 100644 index 0000000000..d5d6ba3d33 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/forward-email.spec.ts @@ -0,0 +1,277 @@ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultForwardEmailOptions } from "../../data"; +import { FORWARD_EMAIL_FORWARDER } from "../storage"; + +import { ForwardEmailForwarder } from "./forward-email"; +import { mockApiService, mockI18nService } from "./mocks.jest"; + +const SomeUser = "some user" as UserId; + +describe("ForwardEmail Forwarder", () => { + it("key returns the Forward Email forwarder key", () => { + const forwarder = new ForwardEmailForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new ForwardEmailForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultForwardEmailOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + domain: "example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.ForwardEmail.name, + ); + }); + + it.each([null, ""])( + "throws an error if the domain is missing (domain = %p)", + async (domain) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain, + }), + ).rejects.toEqual("forwarderNoDomain"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith( + "forwarderNoDomain", + Forwarders.ForwardEmail.name, + ); + }, + ); + + it.each([ + ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], + ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], + ["forwarderGeneratedBy", "not provided", null, ""], + ["forwarderGeneratedBy", "not provided", "", ""], + ])( + "describes the website with %p when the website is %s (= %p)", + async (translationKey, _ignored, website, expectedWebsite) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await forwarder.generate({ + website, + token: "token", + domain: "example.com", + }); + + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); + }, + ); + + it.each([ + ["jane.doe@example.com", 201, { name: "jane.doe", domain: { name: "example.com" } }], + ["jane.doe@example.com", 201, { name: "jane.doe" }], + ["john.doe@example.com", 201, { name: "john.doe", domain: { name: "example.com" } }], + ["john.doe@example.com", 201, { name: "john.doe" }], + ["jane.doe@example.com", 200, { name: "jane.doe", domain: { name: "example.com" } }], + ["jane.doe@example.com", 200, { name: "jane.doe" }], + ["john.doe@example.com", 200, { name: "john.doe", domain: { name: "example.com" } }], + ["john.doe@example.com", 200, { name: "john.doe" }], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (email, status, response) => { + const apiService = mockApiService(status, response); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }); + + expect(result).toEqual(email); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidToken", + Forwarders.ForwardEmail.name, + undefined, + ); + }); + + it("throws an invalid token error with a message if the request fails with a 401 and message", async () => { + const apiService = mockApiService(401, { message: "A message" }); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }), + ).rejects.toEqual("forwaderInvalidTokenWithMessage"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidTokenWithMessage", + Forwarders.ForwardEmail.name, + "A message", + ); + }); + + it.each([{}, null])( + "throws an unknown error if the request fails and no status (= %p) is provided", + async (json) => { + const apiService = mockApiService(500, json); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderUnknownError", + Forwarders.ForwardEmail.name, + ); + }, + ); + + it.each([ + [100, "Continue"], + [202, "Accepted"], + [300, "Multiple Choices"], + [418, "I'm a teapot"], + [500, "Internal Server Error"], + [600, "Unknown Status"], + ])( + "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", + async (statusCode, message) => { + const apiService = mockApiService(statusCode, { message }); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderError", + Forwarders.ForwardEmail.name, + message, + ); + }, + ); + + it.each([ + [100, "Continue"], + [202, "Accepted"], + [300, "Multiple Choices"], + [418, "I'm a teapot"], + [500, "Internal Server Error"], + [600, "Unknown Status"], + ])( + "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", + async (statusCode, error) => { + const apiService = mockApiService(statusCode, { error }); + const i18nService = mockI18nService(); + + const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + domain: "example.com", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderError", + Forwarders.ForwardEmail.name, + error, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/forward-email.ts b/libs/tools/generator/core/src/strategies/forwarders/forward-email.ts new file mode 100644 index 0000000000..90ea0eb52c --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/forward-email.ts @@ -0,0 +1,104 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { Forwarders, DefaultForwardEmailOptions } from "../../data"; +import { EmailDomainOptions, ApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../storage"; + +/** Generates a forwarding address for Forward Email */ +export class ForwardEmailForwarder extends ForwarderGeneratorStrategy< + ApiOptions & EmailDomainOptions +> { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions); + } + + // configuration + readonly key = FORWARD_EMAIL_FORWARDER; + readonly rolloverKey = FORWARD_EMAIL_BUFFER; + + // request + generate = async (options: ApiOptions & EmailDomainOptions) => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name); + throw error; + } + if (!options.domain || options.domain === "") { + const error = this.i18nService.t("forwarderNoDomain", Forwarders.ForwardEmail.name); + throw error; + } + + const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`; + + let descriptionId = "forwarderGeneratedByWithWebsite"; + if (!options.website || options.website === "") { + descriptionId = "forwarderGeneratedBy"; + } + const description = this.i18nService.t(descriptionId, options.website ?? ""); + + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authorization: "Basic " + Utils.fromUtf8ToB64(options.token + ":"), + "Content-Type": "application/json", + }), + body: JSON.stringify({ + labels: options.website, + description, + }), + }); + + const response = await this.apiService.nativeFetch(request); + const json = await response.json(); + + if (response.status === 401) { + const messageKey = + "message" in json ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken"; + const error = this.i18nService.t(messageKey, Forwarders.ForwardEmail.name, json.message); + throw error; + } else if (response.status === 200 || response.status === 201) { + const { name, domain } = await response.json(); + const domainPart = domain?.name || options.domain; + return `${name}@${domainPart}`; + } else if (json?.message) { + const error = this.i18nService.t( + "forwarderError", + Forwarders.ForwardEmail.name, + json.message, + ); + throw error; + } else if (json?.error) { + const error = this.i18nService.t("forwarderError", Forwarders.ForwardEmail.name, json.error); + throw error; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name); + throw error; + } + }; +} + +export const DefaultOptions = Object.freeze({ + website: null, + token: "", + domain: "", +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/mocks.jest.ts b/libs/tools/generator/core/src/strategies/forwarders/mocks.jest.ts new file mode 100644 index 0000000000..fa1f8ae095 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/mocks.jest.ts @@ -0,0 +1,22 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +/** a mock {@link ApiService} that returns a fetch-like response with a given status and body */ +export function mockApiService(status: number, body: any, statusText?: string) { + return { + nativeFetch: jest.fn().mockImplementation((r: Request) => { + return { + status, + statusText, + json: jest.fn().mockImplementation(() => Promise.resolve(body)), + }; + }), + } as unknown as ApiService; +} + +/** a mock {@link I18nService} that returns the translation key */ +export function mockI18nService() { + return { + t: jest.fn().mockImplementation((key: string) => key), + } as unknown as I18nService; +} diff --git a/libs/tools/generator/core/src/strategies/forwarders/simple-login.spec.ts b/libs/tools/generator/core/src/strategies/forwarders/simple-login.spec.ts new file mode 100644 index 0000000000..de10fd9e86 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/simple-login.spec.ts @@ -0,0 +1,209 @@ +import { firstValueFrom } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { Forwarders, DefaultSimpleLoginOptions } from "../../data"; +import { SIMPLE_LOGIN_FORWARDER } from "../storage"; + +import { mockApiService, mockI18nService } from "./mocks.jest"; +import { SimpleLoginForwarder } from "./simple-login"; + +const SomeUser = "some user" as UserId; + +describe("SimpleLogin Forwarder", () => { + it("key returns the Simple Login forwarder key", () => { + const forwarder = new SimpleLoginForwarder(null, null, null, null, null); + + expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new SimpleLoginForwarder(null, null, null, null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultSimpleLoginOptions); + }); + }); + + describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => { + it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token, + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith( + "forwaderInvalidToken", + Forwarders.SimpleLogin.name, + ); + }); + + it.each([null, ""])( + "throws an error if the baseUrl is missing (baseUrl = %p)", + async (baseUrl) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + baseUrl, + }), + ).rejects.toEqual("forwarderNoUrl"); + + expect(apiService.nativeFetch).not.toHaveBeenCalled(); + expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.SimpleLogin.name); + }, + ); + + it.each([ + ["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"], + ["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"], + ["forwarderGeneratedBy", "not provided", null, ""], + ["forwarderGeneratedBy", "not provided", "", ""], + ])( + "describes the website with %p when the website is %s (= %p)", + async (translationKey, _ignored, website, expectedWebsite) => { + const apiService = mockApiService(200, {}); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await forwarder.generate({ + website, + token: "token", + baseUrl: "https://api.example.com", + }); + + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite); + }, + ); + + it.each([ + ["jane.doe@example.com", 201], + ["john.doe@example.com", 201], + ["jane.doe@example.com", 200], + ["john.doe@example.com", 200], + ])( + "returns the generated email address (= %p) if the request is successful (status = %p)", + async (alias, status) => { + const apiService = mockApiService(status, { alias }); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + const result = await forwarder.generate({ + website: null, + token: "token", + baseUrl: "https://api.example.com", + }); + + expect(result).toEqual(alias); + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + }, + ); + + it("throws an invalid token error if the request fails with a 401", async () => { + const apiService = mockApiService(401, {}); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwaderInvalidToken"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwaderInvalidToken", + Forwarders.SimpleLogin.name, + ); + }); + + it.each([{}, null])( + "throws an unknown error if the request fails and no status (=%p) is provided", + async (body) => { + const apiService = mockApiService(500, body); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderUnknownError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderUnknownError", + Forwarders.SimpleLogin.name, + ); + }, + ); + + it.each([ + [100, "Continue"], + [202, "Accepted"], + [300, "Multiple Choices"], + [418, "I'm a teapot"], + [500, "Internal Server Error"], + [600, "Unknown Status"], + ])( + "throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided", + async (statusCode, error) => { + const apiService = mockApiService(statusCode, { error }); + const i18nService = mockI18nService(); + + const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null); + + await expect( + async () => + await forwarder.generate({ + website: null, + token: "token", + baseUrl: "https://api.example.com", + }), + ).rejects.toEqual("forwarderError"); + + expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request)); + // counting instances is terribly flaky over changes, but jest doesn't have a better way to do this + expect(i18nService.t).toHaveBeenNthCalledWith( + 2, + "forwarderError", + Forwarders.SimpleLogin.name, + error, + ); + }, + ); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/forwarders/simple-login.ts b/libs/tools/generator/core/src/strategies/forwarders/simple-login.ts new file mode 100644 index 0000000000..30723b19e6 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/forwarders/simple-login.ts @@ -0,0 +1,88 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.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 { StateProvider } from "@bitwarden/common/platform/state"; + +import { Forwarders, DefaultSimpleLoginOptions } from "../../data"; +import { SelfHostedApiOptions } from "../../types"; +import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy"; +import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../storage"; + +/** Generates a forwarding address for Simple Login */ +export class SimpleLoginForwarder extends ForwarderGeneratorStrategy { + /** Instantiates the forwarder + * @param apiService used for ajax requests to the forwarding service + * @param i18nService used to look up error strings + * @param encryptService protects sensitive forwarder options + * @param keyService looks up the user key when protecting data. + * @param stateProvider creates the durable state for options storage + */ + constructor( + private apiService: ApiService, + private i18nService: I18nService, + encryptService: EncryptService, + keyService: CryptoService, + stateProvider: StateProvider, + ) { + super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions); + } + + // configuration + readonly key = SIMPLE_LOGIN_FORWARDER; + readonly rolloverKey = SIMPLE_LOGIN_BUFFER; + + // request + generate = async (options: SelfHostedApiOptions) => { + if (!options.token || options.token === "") { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); + throw error; + } + if (!options.baseUrl || options.baseUrl === "") { + const error = this.i18nService.t("forwarderNoUrl", Forwarders.SimpleLogin.name); + throw error; + } + + let url = options.baseUrl + "/api/alias/random/new"; + let noteId = "forwarderGeneratedBy"; + if (options.website && options.website !== "") { + url += "?hostname=" + options.website; + noteId = "forwarderGeneratedByWithWebsite"; + } + const note = this.i18nService.t(noteId, options.website ?? ""); + + const request = new Request(url, { + redirect: "manual", + cache: "no-store", + method: "POST", + headers: new Headers({ + Authentication: options.token, + "Content-Type": "application/json", + }), + body: JSON.stringify({ note }), + }); + + const response = await this.apiService.nativeFetch(request); + if (response.status === 401) { + const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name); + throw error; + } + + const json = await response.json(); + if (response.status === 200 || response.status === 201) { + return json.alias; + } else if (json?.error) { + const error = this.i18nService.t("forwarderError", Forwarders.SimpleLogin.name, json.error); + throw error; + } else { + const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name); + throw error; + } + }; +} + +export const DefaultOptions = Object.freeze({ + website: null, + baseUrl: "https://app.simplelogin.io", + token: "", +}); diff --git a/libs/tools/generator/core/src/strategies/index.ts b/libs/tools/generator/core/src/strategies/index.ts new file mode 100644 index 0000000000..61c6a3ef20 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/index.ts @@ -0,0 +1,11 @@ +export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; +export { PasswordGeneratorStrategy } from "./password-generator-strategy"; +export { CatchallGeneratorStrategy } from "./catchall-generator-strategy"; +export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; +export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy"; +export { AddyIoForwarder } from "./forwarders/addy-io"; +export { DuckDuckGoForwarder } from "./forwarders/duck-duck-go"; +export { FastmailForwarder } from "./forwarders/fastmail"; +export { FirefoxRelayForwarder } from "./forwarders/firefox-relay"; +export { ForwardEmailForwarder } from "./forwarders/forward-email"; +export { SimpleLoginForwarder } from "./forwarders/simple-login"; diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts new file mode 100644 index 0000000000..3620fd76f2 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.spec.ts @@ -0,0 +1,92 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Randomizer } from "../abstractions"; +import { DefaultPassphraseGenerationOptions, DisabledPassphraseGeneratorPolicy } from "../data"; +import { PassphraseGeneratorOptionsEvaluator } from "../policies"; + +import { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy"; +import { PASSPHRASE_SETTINGS } from "./storage"; + +const SomeUser = "some user" as UserId; + +describe("Password generation strategy", () => { + describe("toEvaluator()", () => { + it("should map to the policy evaluator", async () => { + const strategy = new PassphraseGeneratorStrategy(null, null); + const policy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minNumberWords: 10, + capitalize: true, + includeNumber: true, + }, + }); + + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject({ + minNumberWords: 10, + capitalize: true, + includeNumber: true, + }); + }); + + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PassphraseGeneratorStrategy(null, null); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy); + }, + ); + }); + + describe("durableState", () => { + it("should use password settings key", () => { + const provider = mock(); + const randomizer = mock(); + const strategy = new PassphraseGeneratorStrategy(randomizer, provider); + + strategy.durableState(SomeUser); + + expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSPHRASE_SETTINGS); + }); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new PassphraseGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultPassphraseGenerationOptions); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const randomizer = mock(); + const strategy = new PassphraseGeneratorStrategy(randomizer, null); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it.todo("should generate a password using the given options"); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts new file mode 100644 index 0000000000..023c9f531d --- /dev/null +++ b/libs/tools/generator/core/src/strategies/passphrase-generator-strategy.ts @@ -0,0 +1,66 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { DefaultPassphraseGenerationOptions, Policies } from "../data"; +import { mapPolicyToEvaluator } from "../rx"; +import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; + +import { PASSPHRASE_SETTINGS } from "./storage"; + +/** Generates passphrases composed of random words */ +export class PassphraseGeneratorStrategy + implements GeneratorStrategy +{ + /** instantiates the password generator strategy. + * @param legacy generates the passphrase + * @param stateProvider provides durable state + */ + constructor( + private randomizer: Randomizer, + private stateProvider: StateProvider, + ) {} + + // configuration + durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions); + readonly policy = PolicyType.PasswordGenerator; + toEvaluator() { + return mapPolicyToEvaluator(Policies.Passphrase); + } + + // algorithm + async generate(options: PassphraseGenerationOptions): Promise { + const o = { ...DefaultPassphraseGenerationOptions, ...options }; + if (o.numWords == null || o.numWords <= 2) { + o.numWords = DefaultPassphraseGenerationOptions.numWords; + } + if (o.capitalize == null) { + o.capitalize = false; + } + if (o.includeNumber == null) { + o.includeNumber = false; + } + + // select which word gets the number, if any + let luckyNumber = -1; + if (o.includeNumber) { + luckyNumber = await this.randomizer.uniform(0, o.numWords); + } + + // generate the passphrase + const wordList = new Array(o.numWords); + for (let i = 0; i < o.numWords; i++) { + const word = await this.randomizer.pickWord(EFFLongWordList, { + titleCase: o.capitalize, + number: i === luckyNumber, + }); + + wordList[i] = word; + } + + return wordList.join(o.wordSeparator); + } +} diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts new file mode 100644 index 0000000000..c1c1355d1a --- /dev/null +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.spec.ts @@ -0,0 +1,100 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Randomizer } from "../abstractions"; +import { DefaultPasswordGenerationOptions, DisabledPasswordGeneratorPolicy } from "../data"; +import { PasswordGeneratorOptionsEvaluator } from "../policies"; + +import { PasswordGeneratorStrategy } from "./password-generator-strategy"; +import { PASSWORD_SETTINGS } from "./storage"; + +const SomeUser = "some user" as UserId; + +describe("Password generation strategy", () => { + describe("toEvaluator()", () => { + it("should map to a password policy evaluator", async () => { + const strategy = new PasswordGeneratorStrategy(null, null); + const policy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + useUpper: true, + useLower: true, + useNumbers: true, + minNumbers: 1, + useSpecial: true, + minSpecial: 1, + }, + }); + + const evaluator$ = of([policy]).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject({ + minLength: 10, + useUppercase: true, + useLowercase: true, + useNumbers: true, + numberCount: 1, + useSpecial: true, + specialCount: 1, + }); + }); + + it.each([[[]], [null], [undefined]])( + "should map `%p` to a disabled password policy evaluator", + async (policies) => { + const strategy = new PasswordGeneratorStrategy(null, null); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator); + expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy); + }, + ); + }); + + describe("durableState", () => { + it("should use password settings key", () => { + const provider = mock(); + const randomizer = mock(); + const strategy = new PasswordGeneratorStrategy(randomizer, provider); + + strategy.durableState(SomeUser); + + expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSWORD_SETTINGS); + }); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new PasswordGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultPasswordGenerationOptions); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const randomizer = mock(); + const strategy = new PasswordGeneratorStrategy(randomizer, null); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it.todo("should generate a password using the given options"); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/password-generator-strategy.ts b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts new file mode 100644 index 0000000000..d8e59d3105 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/password-generator-strategy.ts @@ -0,0 +1,124 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { Policies, DefaultPasswordGenerationOptions } from "../data"; +import { mapPolicyToEvaluator } from "../rx"; +import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; + +import { PASSWORD_SETTINGS } from "./storage"; + +/** Generates passwords composed of random characters */ +export class PasswordGeneratorStrategy + implements GeneratorStrategy +{ + /** instantiates the password generator strategy. + * @param legacy generates the password + */ + constructor( + private randomizer: Randomizer, + private stateProvider: StateProvider, + ) {} + + // configuration + durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions); + readonly policy = PolicyType.PasswordGenerator; + toEvaluator() { + return mapPolicyToEvaluator(Policies.Password); + } + + // algorithm + async generate(options: PasswordGenerationOptions): Promise { + const o = { ...DefaultPasswordGenerationOptions, ...options }; + let positions: string[] = []; + if (o.lowercase && o.minLowercase > 0) { + for (let i = 0; i < o.minLowercase; i++) { + positions.push("l"); + } + } + if (o.uppercase && o.minUppercase > 0) { + for (let i = 0; i < o.minUppercase; i++) { + positions.push("u"); + } + } + if (o.number && o.minNumber > 0) { + for (let i = 0; i < o.minNumber; i++) { + positions.push("n"); + } + } + if (o.special && o.minSpecial > 0) { + for (let i = 0; i < o.minSpecial; i++) { + positions.push("s"); + } + } + while (positions.length < o.length) { + positions.push("a"); + } + + // shuffle + positions = await this.randomizer.shuffle(positions); + + // build out the char sets + let allCharSet = ""; + + let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz"; + if (o.ambiguous) { + lowercaseCharSet += "l"; + } + if (o.lowercase) { + allCharSet += lowercaseCharSet; + } + + let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + if (o.ambiguous) { + uppercaseCharSet += "IO"; + } + if (o.uppercase) { + allCharSet += uppercaseCharSet; + } + + let numberCharSet = "23456789"; + if (o.ambiguous) { + numberCharSet += "01"; + } + if (o.number) { + allCharSet += numberCharSet; + } + + const specialCharSet = "!@#$%^&*"; + if (o.special) { + allCharSet += specialCharSet; + } + + let password = ""; + for (let i = 0; i < o.length; i++) { + let positionChars: string; + switch (positions[i]) { + case "l": + positionChars = lowercaseCharSet; + break; + case "u": + positionChars = uppercaseCharSet; + break; + case "n": + positionChars = numberCharSet; + break; + case "s": + positionChars = specialCharSet; + break; + case "a": + positionChars = allCharSet; + break; + default: + break; + } + + const randomCharIndex = await this.randomizer.uniform(0, positionChars.length - 1); + password += positionChars.charAt(randomCharIndex); + } + + return password; + } +} diff --git a/libs/tools/generator/core/src/strategies/storage.spec.ts b/libs/tools/generator/core/src/strategies/storage.spec.ts new file mode 100644 index 0000000000..4a11d5887e --- /dev/null +++ b/libs/tools/generator/core/src/strategies/storage.spec.ts @@ -0,0 +1,169 @@ +import { + EFF_USERNAME_SETTINGS, + CATCHALL_SETTINGS, + SUBADDRESS_SETTINGS, + PASSPHRASE_SETTINGS, + PASSWORD_SETTINGS, + SIMPLE_LOGIN_FORWARDER, + FORWARD_EMAIL_FORWARDER, + FIREFOX_RELAY_FORWARDER, + FASTMAIL_FORWARDER, + DUCK_DUCK_GO_FORWARDER, + ADDY_IO_FORWARDER, + ADDY_IO_BUFFER, + DUCK_DUCK_GO_BUFFER, + FASTMAIL_BUFFER, + FIREFOX_RELAY_BUFFER, + FORWARD_EMAIL_BUFFER, + SIMPLE_LOGIN_BUFFER, +} from "./storage"; + +describe("Key definitions", () => { + describe("PASSWORD_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = PASSWORD_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("PASSPHRASE_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = PASSPHRASE_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("EFF_USERNAME_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = { website: null as string }; + const result = EFF_USERNAME_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("CATCHALL_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = { website: null as string }; + const result = CATCHALL_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("SUBADDRESS_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = { website: null as string }; + const result = SUBADDRESS_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("ADDY_IO_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = ADDY_IO_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("DUCK_DUCK_GO_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = DUCK_DUCK_GO_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("FASTMAIL_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = FASTMAIL_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("FIREFOX_RELAY_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = FIREFOX_RELAY_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("FORWARD_EMAIL_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = FORWARD_EMAIL_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("SIMPLE_LOGIN_FORWARDER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + const result = SIMPLE_LOGIN_FORWARDER.deserializer(value); + expect(result).toBe(value); + }); + }); + + describe("ADDY_IO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = ADDY_IO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("DUCK_DUCK_GO_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FASTMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FASTMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FIREFOX_RELAY_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FIREFOX_RELAY_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("FORWARD_EMAIL_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = FORWARD_EMAIL_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); + + describe("SIMPLE_LOGIN_BUFFER", () => { + it("should pass through deserialization", () => { + const value: any = {}; + + const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value); + + expect(result).toBe(value); + }); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/storage.ts b/libs/tools/generator/core/src/strategies/storage.ts new file mode 100644 index 0000000000..5bb746ff94 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/storage.ts @@ -0,0 +1,184 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; + +import { + PassphraseGenerationOptions, + PasswordGenerationOptions, + CatchallGenerationOptions, + EffUsernameGenerationOptions, + ApiOptions, + EmailDomainOptions, + EmailPrefixOptions, + SelfHostedApiOptions, + SubaddressGenerationOptions, +} from "../types"; + +/** plaintext password generation options */ +export const PASSWORD_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "passwordGeneratorSettings", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** plaintext passphrase generation options */ +export const PASSPHRASE_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "passphraseGeneratorSettings", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** plaintext username generation options */ +export const EFF_USERNAME_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "effUsernameGeneratorSettings", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** plaintext configuration for a domain catch-all address. */ +export const CATCHALL_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "catchallGeneratorSettings", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** plaintext configuration for an email subaddress. */ +export const SUBADDRESS_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "subaddressGeneratorSettings", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.AddyIo} */ +export const ADDY_IO_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "addyIoForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.DuckDuckGo} */ +export const DUCK_DUCK_GO_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "duckDuckGoForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.FastMail} */ +export const FASTMAIL_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "fastmailForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.FireFoxRelay} */ +export const FIREFOX_RELAY_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "firefoxRelayForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.ForwardEmail} */ +export const FORWARD_EMAIL_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "forwardEmailForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link forwarders.SimpleLogin} */ +export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition( + GENERATOR_DISK, + "simpleLoginForwarder", + { + deserializer: (value) => value, + clearOn: [], + }, +); + +/** backing store configuration for {@link Forwarders.AddyIo} */ +export const ADDY_IO_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "addyIoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.DuckDuckGo} */ +export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "duckDuckGoBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FastMail} */ +export const FASTMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "fastmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.FireFoxRelay} */ +export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "firefoxRelayBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link Forwarders.ForwardEmail} */ +export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "forwardEmailBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); + +/** backing store configuration for {@link forwarders.SimpleLogin} */ +export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition( + GENERATOR_DISK, + "simpleLoginBuffer", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); diff --git a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts new file mode 100644 index 0000000000..e40832eb72 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.spec.ts @@ -0,0 +1,75 @@ +import { mock } from "jest-mock-extended"; +import { of, firstValueFrom } from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Randomizer } from "../abstractions"; +import { DefaultSubaddressOptions } from "../data"; +import { DefaultPolicyEvaluator } from "../policies"; + +import { SUBADDRESS_SETTINGS } from "./storage"; +import { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy"; + +const SomeUser = "some user" as UserId; +const SomePolicy = mock({ + type: PolicyType.PasswordGenerator, + data: { + minLength: 10, + }, +}); + +describe("Email subaddress list generation strategy", () => { + describe("toEvaluator()", () => { + it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])( + "should map any input (= %p) to the default policy evaluator", + async (policies) => { + const strategy = new SubaddressGeneratorStrategy(null, null); + + const evaluator$ = of(policies).pipe(strategy.toEvaluator()); + const evaluator = await firstValueFrom(evaluator$); + + expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator); + }, + ); + }); + + describe("durableState", () => { + it("should use password settings key", () => { + const provider = mock(); + const randomizer = mock(); + const strategy = new SubaddressGeneratorStrategy(randomizer, provider); + + strategy.durableState(SomeUser); + + expect(provider.getUser).toHaveBeenCalledWith(SomeUser, SUBADDRESS_SETTINGS); + }); + }); + + describe("defaults$", () => { + it("should return the default subaddress options", async () => { + const strategy = new SubaddressGeneratorStrategy(null, null); + + const result = await firstValueFrom(strategy.defaults$(SomeUser)); + + expect(result).toEqual(DefaultSubaddressOptions); + }); + }); + + describe("policy", () => { + it("should use password generator policy", () => { + const randomizer = mock(); + const strategy = new SubaddressGeneratorStrategy(randomizer, null); + + expect(strategy.policy).toBe(PolicyType.PasswordGenerator); + }); + }); + + describe("generate()", () => { + it.todo("generate email subaddress tests"); + }); +}); diff --git a/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts new file mode 100644 index 0000000000..51d698ea95 --- /dev/null +++ b/libs/tools/generator/core/src/strategies/subaddress-generator-strategy.ts @@ -0,0 +1,62 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; + +import { GeneratorStrategy, Randomizer } from "../abstractions"; +import { DefaultSubaddressOptions } from "../data"; +import { newDefaultEvaluator } from "../rx"; +import { SubaddressGenerationOptions, NoPolicy } from "../types"; +import { clone$PerUserId, sharedStateByUserId } from "../util"; + +import { SUBADDRESS_SETTINGS } from "./storage"; + +/** Strategy for creating an email subaddress + * @remarks The subaddress is the part following the `+`. + * For example, if the email address is `jd+xyz@domain.io`, + * the subaddress is `xyz`. + */ +export class SubaddressGeneratorStrategy + implements GeneratorStrategy +{ + /** Instantiates the generation strategy + * @param usernameService generates an email subaddress from an email address + */ + constructor( + private random: Randomizer, + private stateProvider: StateProvider, + private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions, + ) {} + + // configuration + durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider); + defaults$ = clone$PerUserId(this.defaultOptions); + toEvaluator = newDefaultEvaluator(); + readonly policy = PolicyType.PasswordGenerator; + + // algorithm + async generate(options: SubaddressGenerationOptions) { + const o = Object.assign({}, DefaultSubaddressOptions, options); + + const subaddressEmail = o.subaddressEmail; + if (subaddressEmail == null || subaddressEmail.length < 3) { + return o.subaddressEmail; + } + const atIndex = subaddressEmail.indexOf("@"); + if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) { + return subaddressEmail; + } + if (o.subaddressType == null) { + o.subaddressType = "random"; + } + + const emailBeginning = subaddressEmail.substr(0, atIndex); + const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length); + + let subaddressString = ""; + if (o.subaddressType === "random") { + subaddressString = await this.random.chars(8); + } else if (o.subaddressType === "website-name") { + subaddressString = o.website; + } + return emailBeginning + "+" + subaddressString + "@" + emailEnding; + } +} diff --git a/libs/tools/generator/core/src/types/boundary.ts b/libs/tools/generator/core/src/types/boundary.ts new file mode 100644 index 0000000000..686e0f5960 --- /dev/null +++ b/libs/tools/generator/core/src/types/boundary.ts @@ -0,0 +1,4 @@ +export type Boundary = { + readonly min: number; + readonly max: number; +}; diff --git a/libs/tools/generator/core/src/types/catchall-generator-options.ts b/libs/tools/generator/core/src/types/catchall-generator-options.ts new file mode 100644 index 0000000000..157bb7fa93 --- /dev/null +++ b/libs/tools/generator/core/src/types/catchall-generator-options.ts @@ -0,0 +1,14 @@ +import { RequestOptions } from "./forwarder-options"; +import { UsernameGenerationMode } from "./generator-options"; + +/** Settings supported when generating an email subaddress */ +export type CatchallGenerationOptions = { + /** selects the generation algorithm for the catchall email address. */ + catchallType?: UsernameGenerationMode; + + /** The domain part of the generated email address. + * @example If the domain is `domain.io` and the generated username + * is `jd`, then the generated email address will be `jd@mydomain.io` + */ + catchallDomain?: string; +} & RequestOptions; diff --git a/libs/tools/generator/core/src/types/eff-username-generator-options.ts b/libs/tools/generator/core/src/types/eff-username-generator-options.ts new file mode 100644 index 0000000000..812cad2c43 --- /dev/null +++ b/libs/tools/generator/core/src/types/eff-username-generator-options.ts @@ -0,0 +1,10 @@ +import { RequestOptions } from "./forwarder-options"; + +/** Settings supported when generating a username using the EFF word list */ +export type EffUsernameGenerationOptions = { + /** when true, the word is capitalized */ + wordCapitalize?: boolean; + + /** when true, a random number is appended to the username */ + wordIncludeNumber?: boolean; +} & RequestOptions; diff --git a/libs/tools/generator/core/src/types/forwarder-options.ts b/libs/tools/generator/core/src/types/forwarder-options.ts new file mode 100644 index 0000000000..f36a58a0db --- /dev/null +++ b/libs/tools/generator/core/src/types/forwarder-options.ts @@ -0,0 +1,72 @@ +/** Identifiers for email forwarding services. + * @remarks These are used to select forwarder-specific options. + * The must be kept in sync with the forwarder implementations. + */ +export type ForwarderId = + | "anonaddy" + | "duckduckgo" + | "fastmail" + | "firefoxrelay" + | "forwardemail" + | "simplelogin"; + +/** Metadata format for email forwarding services. */ +export type ForwarderMetadata = { + /** The unique identifier for the forwarder. */ + id: ForwarderId; + + /** The name of the service the forwarder queries. */ + name: string; + + /** Whether the forwarder is valid for self-hosted instances of Bitwarden. */ + validForSelfHosted: boolean; +}; + +/** Options common to all forwarder APIs */ +export type ApiOptions = { + /** bearer token that authenticates bitwarden to the forwarder. + * This is required to issue an API request. + */ + token?: string; +} & RequestOptions; + +/** Options that provide contextual information about the application state + * when a forwarder is invoked. + * @remarks these fields should always be omitted when saving options. + */ +export type RequestOptions = { + /** @param website The domain of the website the generated email is used + * within. This should be set to `null` when the request is not specific + * to any website. + */ + website: string | null; +}; + +/** Api configuration for forwarders that support self-hosted installations. */ +export type SelfHostedApiOptions = ApiOptions & { + /** The base URL of the forwarder's API. + * When this is empty, the forwarder's default production API is used. + */ + baseUrl: string; +}; + +/** Api configuration for forwarders that support custom domains. */ +export type EmailDomainOptions = { + /** The domain part of the generated email address. + * @remarks The domain should be authorized by the forwarder before + * submitting a request through bitwarden. + * @example If the domain is `domain.io` and the generated username + * is `jd`, then the generated email address will be `jd@mydomain.io` + */ + domain: string; +}; + +/** Api configuration for forwarders that support custom email parts. */ +export type EmailPrefixOptions = EmailDomainOptions & { + /** A prefix joined to the generated email address' username. + * @example If the prefix is `foo`, the generated username is `bar`, + * and the domain is `domain.io`, then the generated email address is ` + * then the generated username is `foobar@domain.io`. + */ + prefix: string; +}; diff --git a/libs/tools/generator/core/src/types/generator-options.ts b/libs/tools/generator/core/src/types/generator-options.ts new file mode 100644 index 0000000000..3df5709ed3 --- /dev/null +++ b/libs/tools/generator/core/src/types/generator-options.ts @@ -0,0 +1,13 @@ +/** ways you can generate usernames + * "word" generates a username from the eff word list + * "subaddress" creates a subaddress of an email. + * "catchall" uses a domain's catchall address + * "forwarded" uses an email forwarding service + */ +export type UsernameGeneratorType = "word" | "subaddress" | "catchall" | "forwarded"; + +/** Several username generators support two generation modes + * "random" selects one or more random words from the EFF word list + * "website-name" includes the domain in the generated username + */ +export type UsernameGenerationMode = "random" | "website-name"; diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts new file mode 100644 index 0000000000..f17eeb9c92 --- /dev/null +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -0,0 +1,2 @@ +/** The kind of credential being generated. */ +export type GeneratorType = "password" | "passphrase" | "username"; diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts new file mode 100644 index 0000000000..7a6d49d87c --- /dev/null +++ b/libs/tools/generator/core/src/types/index.ts @@ -0,0 +1,14 @@ +export * from "./boundary"; +export * from "./catchall-generator-options"; +export * from "./eff-username-generator-options"; +export * from "./forwarder-options"; +export * from "./generator-options"; +export * from "./generator-type"; +export * from "./no-policy"; +export * from "./passphrase-generation-options"; +export * from "./passphrase-generator-policy"; +export * from "./password-generation-options"; +export * from "./password-generator-policy"; +export * from "./policy-configuration"; +export * from "./subaddress-generator-options"; +export * from "./word-options"; diff --git a/libs/tools/generator/core/src/types/no-policy.ts b/libs/tools/generator/core/src/types/no-policy.ts new file mode 100644 index 0000000000..00ffc6098c --- /dev/null +++ b/libs/tools/generator/core/src/types/no-policy.ts @@ -0,0 +1,2 @@ +/** Type representing an absence of policy. */ +export type NoPolicy = Record; diff --git a/libs/tools/generator/core/src/types/passphrase-generation-options.ts b/libs/tools/generator/core/src/types/passphrase-generation-options.ts new file mode 100644 index 0000000000..c3ae59b9e5 --- /dev/null +++ b/libs/tools/generator/core/src/types/passphrase-generation-options.ts @@ -0,0 +1,26 @@ +/** Request format for passphrase credential generation. + * The members of this type may be `undefined` when the user is + * generating a password. + */ +export type PassphraseGenerationOptions = { + /** The number of words to include in the passphrase. + * This value defaults to 3. + */ + numWords?: number; + + /** The ASCII separator character to use between words in the passphrase. + * This value defaults to a dash. + * If multiple characters appear in the string, only the first character is used. + */ + wordSeparator?: string; + + /** `true` when the first character of every word should be capitalized. + * This value defaults to `false`. + */ + capitalize?: boolean; + + /** `true` when a number should be included in the passphrase. + * This value defaults to `false`. + */ + includeNumber?: boolean; +}; diff --git a/libs/tools/generator/core/src/types/passphrase-generator-policy.ts b/libs/tools/generator/core/src/types/passphrase-generator-policy.ts new file mode 100644 index 0000000000..5ffd89d91c --- /dev/null +++ b/libs/tools/generator/core/src/types/passphrase-generator-policy.ts @@ -0,0 +1,6 @@ +/** Policy options enforced during passphrase generation. */ +export type PassphraseGeneratorPolicy = { + minNumberWords: number; + capitalize: boolean; + includeNumber: boolean; +}; diff --git a/libs/tools/generator/core/src/types/password-generation-options.ts b/libs/tools/generator/core/src/types/password-generation-options.ts new file mode 100644 index 0000000000..0272cce205 --- /dev/null +++ b/libs/tools/generator/core/src/types/password-generation-options.ts @@ -0,0 +1,68 @@ +/** Request format for password credential generation. + * All members of this type may be `undefined` when the user is + * generating a passphrase. + * + * @remarks The name of this type is a bit of a misnomer. This type + * it is used with the "password generator" types. The name + * `PasswordGeneratorOptions` is already in use by legacy code. + */ +export type PasswordGenerationOptions = { + /** The length of the password selected by the user */ + length?: number; + + /** The minimum length of the password. This defaults to 5, and increases + * to ensure `minLength` is at least as large as the sum of the other minimums. + */ + minLength?: number; + + /** `true` when ambiguous characters may be included in the output. + * `false` when ambiguous characters should not be included in the output. + */ + ambiguous?: boolean; + + /** `true` when uppercase ASCII characters should be included in the output + * This value defaults to `false. + */ + uppercase?: boolean; + + /** The minimum number of uppercase characters to include in the output. + * The value is ignored when `uppercase` is `false`. + * The value defaults to 1 when `uppercase` is `true`. + */ + minUppercase?: number; + + /** `true` when lowercase ASCII characters should be included in the output. + * This value defaults to `false`. + */ + lowercase?: boolean; + + /** The minimum number of lowercase characters to include in the output. + * The value defaults to 1 when `lowercase` is `true`. + * The value defaults to 0 when `lowercase` is `false`. + */ + minLowercase?: number; + + /** Whether or not to include ASCII digits in the output + * This value defaults to `true` when `minNumber` is at least 1. + * This value defaults to `false` when `minNumber` is less than 1. + */ + number?: boolean; + + /** The minimum number of digits to include in the output. + * The value defaults to 1 when `number` is `true`. + * The value defaults to 0 when `number` is `false`. + */ + minNumber?: number; + + /** Whether or not to include special characters in the output. + * This value defaults to `true` when `minSpecial` is at least 1. + * This value defaults to `false` when `minSpecial` is less than 1. + */ + special?: boolean; + + /** The minimum number of special characters to include in the output. + * This value defaults to 1 when `special` is `true`. + * This value defaults to 0 when `special` is `false`. + */ + minSpecial?: number; +}; diff --git a/libs/tools/generator/core/src/types/password-generator-policy.ts b/libs/tools/generator/core/src/types/password-generator-policy.ts new file mode 100644 index 0000000000..b1206f166d --- /dev/null +++ b/libs/tools/generator/core/src/types/password-generator-policy.ts @@ -0,0 +1,39 @@ +/** Policy options enforced during password generation. */ +export type PasswordGeneratorPolicy = { + /** The minimum length of generated passwords. + * When this is less than or equal to zero, it is ignored. + * If this is less than the total number of characters required by + * the policy's other settings, then it is ignored. + */ + minLength: number; + + /** When this is true, an uppercase character must be part of + * the generated password. + */ + useUppercase: boolean; + + /** When this is true, a lowercase character must be part of + * the generated password. + */ + useLowercase: boolean; + + /** When this is true, at least one digit must be part of the generated + * password. + */ + useNumbers: boolean; + + /** The quantity of digits to include in the generated password. + * When this is less than or equal to zero, it is ignored. + */ + numberCount: number; + + /** When this is true, at least one digit must be part of the generated + * password. + */ + useSpecial: boolean; + + /** The quantity of special characters to include in the generated + * password. When this is less than or equal to zero, it is ignored. + */ + specialCount: number; +}; diff --git a/libs/tools/generator/core/src/types/policy-configuration.ts b/libs/tools/generator/core/src/types/policy-configuration.ts new file mode 100644 index 0000000000..afecbe7d2b --- /dev/null +++ b/libs/tools/generator/core/src/types/policy-configuration.ts @@ -0,0 +1,16 @@ +import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy"; + +/** Determines how to construct a password generator policy */ +export type PolicyConfiguration = { + /** The value of the policy when it is not in effect. */ + disabledValue: Policy; + + /** Combines multiple policies set by the administrative console into + * a single policy. + */ + combine: (acc: Policy, policy: AdminPolicy) => Policy; + + /** Converts policy service data into an actionable policy. + */ + createEvaluator: (policy: Policy) => Evaluator; +}; diff --git a/libs/tools/generator/core/src/types/subaddress-generator-options.ts b/libs/tools/generator/core/src/types/subaddress-generator-options.ts new file mode 100644 index 0000000000..67b545d2fb --- /dev/null +++ b/libs/tools/generator/core/src/types/subaddress-generator-options.ts @@ -0,0 +1,11 @@ +import { RequestOptions } from "./forwarder-options"; +import { UsernameGenerationMode } from "./generator-options"; + +/** Settings supported when generating an email subaddress */ +export type SubaddressGenerationOptions = { + /** selects the generation algorithm for the catchall email address. */ + subaddressType?: UsernameGenerationMode; + + /** the email address the subaddress is applied to. */ + subaddressEmail?: string; +} & RequestOptions; diff --git a/libs/tools/generator/core/src/types/word-options.ts b/libs/tools/generator/core/src/types/word-options.ts new file mode 100644 index 0000000000..1c98d0bac8 --- /dev/null +++ b/libs/tools/generator/core/src/types/word-options.ts @@ -0,0 +1,6 @@ +export type WordOptions = { + /** set the first letter uppercase */ + titleCase?: boolean; + /** append a number */ + number?: boolean; +}; diff --git a/libs/tools/generator/core/src/util.ts b/libs/tools/generator/core/src/util.ts new file mode 100644 index 0000000000..db131d3b48 --- /dev/null +++ b/libs/tools/generator/core/src/util.ts @@ -0,0 +1,45 @@ +import { BehaviorSubject } from "rxjs"; + +import { + SingleUserState, + StateProvider, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +/** construct a method that outputs a copy of `defaultValue` as an observable. */ +export function clone$PerUserId(defaultValue: Value) { + const _subjects = new Map>(); + + return (key: UserId) => { + let value = _subjects.get(key); + + if (value === undefined) { + value = new BehaviorSubject({ ...defaultValue }); + _subjects.set(key, value); + } + + return value.asObservable(); + }; +} + +/** construct a method that caches user-specific states by userid. */ +export function sharedByUserId(create: (userId: UserId) => SingleUserState) { + const _subjects = new Map>(); + + return (key: UserId) => { + let value = _subjects.get(key); + + if (value === undefined) { + value = create(key); + _subjects.set(key, value); + } + + return value; + }; +} + +/** construct a method that loads a user-specific state from the provider. */ +export function sharedStateByUserId(key: UserKeyDefinition, provider: StateProvider) { + return (id: UserId) => provider.getUser(id, key); +} diff --git a/libs/tools/generator/core/tsconfig.json b/libs/tools/generator/core/tsconfig.json index c52bfd7b0d..6eec2cc24a 100644 --- a/libs/tools/generator/core/tsconfig.json +++ b/libs/tools/generator/core/tsconfig.json @@ -1,5 +1,9 @@ { "extends": "../../../shared/tsconfig.libs", - "include": ["src"], + "include": [ + "src", + "../extensions/src/history/generator-history.abstraction.ts", + "../extensions/src/navigation/generator-navigation.service.abstraction.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/libs/tools/generator/extensions/jest.config.js b/libs/tools/generator/extensions/jest.config.js index 91d379b0c3..71ccbc80b6 100644 --- a/libs/tools/generator/extensions/jest.config.js +++ b/libs/tools/generator/extensions/jest.config.js @@ -8,6 +8,6 @@ module.exports = { preset: "ts-jest", testEnvironment: "../../../shared/test.environment.ts", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { - prefix: "/../../../", + prefix: "/../../", }), }; diff --git a/libs/tools/generator/extensions/src/history/generated-credential.spec.ts b/libs/tools/generator/extensions/src/history/generated-credential.spec.ts new file mode 100644 index 0000000000..170030bad1 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/generated-credential.spec.ts @@ -0,0 +1,58 @@ +import { GeneratorCategory, 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 GeneratorCategory, + generationDate: 100, + }); + }); + + it("fromJSON converts Json objects into credentials", () => { + const jsonValue = { + credential: "example", + category: "password" as GeneratorCategory, + generationDate: 100, + }; + + const result = GeneratedCredential.fromJSON(jsonValue); + + expect(result).toBeInstanceOf(GeneratedCredential); + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/history/generated-credential.ts b/libs/tools/generator/extensions/src/history/generated-credential.ts new file mode 100644 index 0000000000..59a9623bf7 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/generated-credential.ts @@ -0,0 +1,47 @@ +import { Jsonify } from "type-fest"; + +import { GeneratorCategory } from "./options"; + +/** 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: GeneratorCategory, + 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) { + 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(), + }; + } +} diff --git a/libs/tools/generator/extensions/src/history/generated-password-history.ts b/libs/tools/generator/extensions/src/history/generated-password-history.ts new file mode 100644 index 0000000000..b4cc9b22fa --- /dev/null +++ b/libs/tools/generator/extensions/src/history/generated-password-history.ts @@ -0,0 +1,9 @@ +export class GeneratedPasswordHistory { + password: string; + date: number; + + constructor(password: string, date: number) { + this.password = password; + this.date = date; + } +} diff --git a/libs/tools/generator/extensions/src/history/generator-history.abstraction.ts b/libs/tools/generator/extensions/src/history/generator-history.abstraction.ts new file mode 100644 index 0000000000..78144c3043 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/generator-history.abstraction.ts @@ -0,0 +1,55 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratorCategory } from "./options"; + +/** Tracks the history of password generations. + * Each user gets their own store. + */ +export abstract class GeneratorHistoryService { + /** Tracks a new credential. When an item with the same `credential` value + * is found, this method does nothing. When the total number of items exceeds + * {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total + * are deleted. + * @param userId identifies the user storing the credential. + * @param credential stored by the history service. + * @param date when the credential was generated. If this is omitted, then the generator + * uses the date the credential was added to the store instead. + * @returns a promise that completes with the added credential. If the credential + * wasn't added, then the promise completes with `null`. + * @remarks this service is not suitable for use with vault items/ciphers. It models only + * a history of an individually generated credential, while a vault item's history + * may contain several credentials that are better modelled as atomic versions of the + * vault item itself. + */ + track: ( + userId: UserId, + credential: string, + category: GeneratorCategory, + date?: Date, + ) => Promise; + + /** Removes a matching credential from the history service. + * @param userId identifies the user taking the credential. + * @param credential to match in the history service. + * @returns A promise that completes with the credential read. If the credential wasn't found, + * the promise completes with null. + * @remarks this can be used to extract an entry when a credential is stored in the vault. + */ + take: (userId: UserId, credential: string) => Promise; + + /** Deletes a user's credential history. + * @param userId identifies the user taking the credential. + * @returns A promise that completes when the history is cleared. + */ + clear: (userId: UserId) => Promise; + + /** Lists all credentials for a user. + * @param userId identifies the user listing the credential. + * @remarks This field is eventually consistent with `track` and `take` operations. + * It is not guaranteed to immediately reflect those changes. + */ + credentials$: (userId: UserId) => Observable; +} diff --git a/libs/tools/generator/extensions/src/history/index.ts b/libs/tools/generator/extensions/src/history/index.ts new file mode 100644 index 0000000000..cecf455674 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/index.ts @@ -0,0 +1,5 @@ +export { GeneratedCredential } from "./generated-credential"; +export { GeneratedPasswordHistory } from "./generated-password-history"; +export { GeneratorHistoryService } from "./generator-history.abstraction"; +export { LocalGeneratorHistoryService } from "./local-generator-history.service"; +export { GeneratorCategory } from "./options"; diff --git a/libs/tools/generator/extensions/src/history/key-definition.spec.ts b/libs/tools/generator/extensions/src/history/key-definition.spec.ts new file mode 100644 index 0000000000..a3445502db --- /dev/null +++ b/libs/tools/generator/extensions/src/history/key-definition.spec.ts @@ -0,0 +1,65 @@ +import { mock } from "jest-mock-extended"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratedPasswordHistory } from "./generated-password-history"; +import { GENERATOR_HISTORY_BUFFER } from "./key-definitions"; +import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; + +describe("Key definitions", () => { + describe("GENERATOR_HISTORY_BUFFER", () => { + describe("options.deserializer", () => { + it("should deserialize generated password history", () => { + const value: any = [{ password: "foo", date: 1 }]; + + const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(value[0]); + expect(result).toBeInstanceOf(GeneratedPasswordHistory); + }); + + it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => { + const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value); + + expect(result).toEqual(undefined); + }); + }); + + it("should map generated password history to generated credentials", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = mock({ + decrypt(value) { + return Promise.resolve(value); + }, + }); + + const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor); + + expect(result).toEqual({ + credential: "foo", + category: "password", + generationDate: new Date(1), + }); + expect(result).toBeInstanceOf(GeneratedCredential); + }); + + describe("isValid", () => { + it("should accept histories with at least one entry", async () => { + const value: any = [new GeneratedPasswordHistory("foo", 1)]; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(true); + }); + + it("should reject histories with no entries", async () => { + const value: any = []; + const decryptor = {} as any; + + const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor); + + expect(result).toEqual(false); + }); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/history/key-definitions.ts b/libs/tools/generator/extensions/src/history/key-definitions.ts new file mode 100644 index 0000000000..187a6c8fc9 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/key-definitions.ts @@ -0,0 +1,42 @@ +import { Jsonify } from "type-fest"; + +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; +import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier"; +import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratedPasswordHistory } from "./generated-password-history"; +import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; + +/** encrypted password generation history */ +export const GENERATOR_HISTORY = SecretKeyDefinition.array( + GENERATOR_DISK, + "localGeneratorHistory", + SecretClassifier.allSecret(), + { + deserializer: GeneratedCredential.fromJSON, + clearOn: ["logout"], + }, +); + +/** encrypted password generation history subject to migration */ +export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition< + GeneratedPasswordHistory[], + GeneratedCredential[], + LegacyPasswordHistoryDecryptor +>(GENERATOR_DISK, "localGeneratorHistoryBuffer", { + deserializer(history) { + const items = history as Jsonify[]; + return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date)); + }, + async isValid(history) { + return history.length ? true : false; + }, + async map(history, decryptor) { + const credentials = await decryptor.decrypt(history); + const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date)); + return mapped; + }, + clearOn: ["logout"], +}); diff --git a/libs/tools/generator/extensions/src/history/legacy-password-history-decryptor.ts b/libs/tools/generator/extensions/src/history/legacy-password-history-decryptor.ts new file mode 100644 index 0000000000..5769d79da4 --- /dev/null +++ b/libs/tools/generator/extensions/src/history/legacy-password-history-decryptor.ts @@ -0,0 +1,30 @@ +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { GeneratedPasswordHistory } from "./generated-password-history"; + +/** Strategy that decrypts a password history */ +export class LegacyPasswordHistoryDecryptor { + constructor( + private userId: UserId, + private cryptoService: CryptoService, + private encryptService: EncryptService, + ) {} + + /** Decrypts a password history. */ + async decrypt(history: GeneratedPasswordHistory[]): Promise { + const key = await this.cryptoService.getUserKey(this.userId); + + const promises = (history ?? []).map(async (item) => { + const encrypted = new EncString(item.password); + const decrypted = await this.encryptService.decryptToUtf8(encrypted, key); + return new GeneratedPasswordHistory(decrypted, item.date); + }); + + const decrypted = await Promise.all(promises); + + return decrypted; + } +} diff --git a/libs/tools/generator/extensions/src/history/local-generator-history.service.spec.ts b/libs/tools/generator/extensions/src/history/local-generator-history.service.spec.ts new file mode 100644 index 0000000000..6ac336960b --- /dev/null +++ b/libs/tools/generator/extensions/src/history/local-generator-history.service.spec.ts @@ -0,0 +1,200 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; + +import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../../common/spec"; + +import { LocalGeneratorHistoryService } from "./local-generator-history.service"; + +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "AnotherUser" as UserId; + +describe("LocalGeneratorHistoryService", () => { + const encryptService = mock(); + const keyService = mock(); + const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; + + beforeEach(() => { + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); + keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("credential$", () => { + it("returns an empty list when no credentials are stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual([]); + }); + }); + + describe("track", () => { + it("stores a password", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "password" }); + }); + + it("stores a passphrase", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "passphrase" }); + }); + + it("stores a specific date when one is provided", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password", new Date(100)); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); + + it("skips storing a credential when it's already stored (ignores category)", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores multiple credentials when the credential value is different", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "secondResult", "password"); + await history.track(SomeUser, "firstResult", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); + expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); + }); + + it("removes history items exceeding maxTotal configuration", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "removed result", "password"); + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores history items in per-user collections", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "some user example", "password"); + await history.track(AnotherUser, "another user example", "password"); + await awaitAsync(); + const [someFirstResult, someSecondResult] = await firstValueFrom( + history.credentials$(SomeUser), + ); + const [anotherFirstResult, anotherSecondResult] = await firstValueFrom( + history.credentials$(AnotherUser), + ); + + expect(someFirstResult).toMatchObject({ + credential: "some user example", + category: "password", + }); + expect(someSecondResult).toBeUndefined(); + expect(anotherFirstResult).toMatchObject({ + credential: "another user example", + category: "password", + }); + expect(anotherSecondResult).toBeUndefined(); + }); + }); + + describe("take", () => { + it("returns null when there are no credentials stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await history.take(SomeUser, "example"); + + expect(result).toBeNull(); + }); + + it("returns null when the credential wasn't found", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "not found"); + + expect(result).toBeNull(); + }); + + it("returns a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "example"); + + expect(result).toMatchObject({ + credential: "example", + category: "password", + }); + }); + + it("removes a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + await history.take(SomeUser, "example"); + await awaitAsync(); + const results = await firstValueFrom(history.credentials$(SomeUser)); + + expect(results).toEqual([]); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/history/local-generator-history.service.ts b/libs/tools/generator/extensions/src/history/local-generator-history.service.ts new file mode 100644 index 0000000000..138a6afa4d --- /dev/null +++ b/libs/tools/generator/extensions/src/history/local-generator-history.service.ts @@ -0,0 +1,145 @@ +import { map } from "rxjs"; + +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; +import { BufferedState } from "@bitwarden/common/tools/state/buffered-state"; +import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; +import { SecretState } from "@bitwarden/common/tools/state/secret-state"; +import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratorHistoryService } from "./generator-history.abstraction"; +import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "./key-definitions"; +import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor"; +import { GeneratorCategory, HistoryServiceOptions } from "./options"; + +const OPTIONS_FRAME_SIZE = 2048; + +/** Tracks the history of password generations local to a device. + * {@link GeneratorHistoryService} + */ +export class LocalGeneratorHistoryService extends GeneratorHistoryService { + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: CryptoService, + private readonly stateProvider: StateProvider, + private readonly options: HistoryServiceOptions = { maxTotal: 100 }, + ) { + super(); + } + + private _credentialStates = new Map>(); + + /** {@link GeneratorHistoryService.track} */ + track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => { + const state = this.getCredentialState(userId); + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + // add the result + result = new GeneratedCredential(credential, category, date ?? Date.now()); + credentials.unshift(result); + + // trim history + const removeAt = Math.max(0, this.options.maxTotal); + credentials.splice(removeAt, Infinity); + + return credentials; + }, + { + shouldUpdate: (credentials) => + !(credentials?.some((f) => f.credential === credential) ?? false), + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.take} */ + take = async (userId: UserId, credential: string) => { + const state = this.getCredentialState(userId); + let credentialIndex: number; + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + [result] = credentials.splice(credentialIndex, 1); + return credentials; + }, + { + shouldUpdate: (credentials) => { + credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1; + return credentialIndex >= 0; + }, + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.take} */ + clear = async (userId: UserId) => { + const state = this.getCredentialState(userId); + const result = (await state.update(() => null)) ?? []; + return result; + }; + + /** {@link GeneratorHistoryService.credentials$} */ + credentials$ = (userId: UserId) => { + return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); + }; + + private getCredentialState(userId: UserId) { + let state = this._credentialStates.get(userId); + + if (!state) { + state = this.createSecretState(userId); + this._credentialStates.set(userId, state); + } + + return state; + } + + private createSecretState(userId: UserId): SingleUserState { + // construct the encryptor + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + // construct the durable state + const state = SecretState.from< + GeneratedCredential[], + number, + GeneratedCredential, + Record, + GeneratedCredential + >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); + + // decryptor is just an algorithm, but it can't run until the key is available; + // providing it via an observable makes running it early impossible + const decryptor = new LegacyPasswordHistoryDecryptor( + userId, + this.keyService, + this.encryptService, + ); + const decryptor$ = this.keyService + .getInMemoryUserKeyFor$(userId) + .pipe(map((key) => key && decryptor)); + + // move data from the old password history once decryptor is available + const buffer = new BufferedState( + this.stateProvider, + GENERATOR_HISTORY_BUFFER, + state, + decryptor$, + ); + + return buffer; + } +} diff --git a/libs/tools/generator/extensions/src/history/options.ts b/libs/tools/generator/extensions/src/history/options.ts new file mode 100644 index 0000000000..53716ec33a --- /dev/null +++ b/libs/tools/generator/extensions/src/history/options.ts @@ -0,0 +1,10 @@ +/** Kinds of credentials that can be stored by the history service */ +export type GeneratorCategory = "password" | "passphrase"; + +/** Configuration options for the history service */ +export type HistoryServiceOptions = { + /** Total number of records retained across all types. + * @remarks Setting this to 0 or less disables history completely. + * */ + maxTotal: number; +}; diff --git a/libs/tools/generator/extensions/src/index.ts b/libs/tools/generator/extensions/src/index.ts index e69de29bb2..d266519192 100644 --- a/libs/tools/generator/extensions/src/index.ts +++ b/libs/tools/generator/extensions/src/index.ts @@ -0,0 +1,4 @@ +export * as history from "./history"; +export * as legacyPassword from "./legacy-password"; +export * as legacyUsername from "./legacy-username"; +export * as navigation from "./navigation"; diff --git a/libs/tools/generator/extensions/src/legacy-password/factory.ts b/libs/tools/generator/extensions/src/legacy-password/factory.ts new file mode 100644 index 0000000000..578a8a40bb --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/factory.ts @@ -0,0 +1,49 @@ +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +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 { StateProvider } from "@bitwarden/common/platform/state"; +import { engine, services, strategies } from "@bitwarden/generator-core"; + +import { LocalGeneratorHistoryService } from "../history"; +import { DefaultGeneratorNavigationService } from "../navigation"; + +import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; +import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; + +const PassphraseGeneratorStrategy = strategies.PassphraseGeneratorStrategy; +const PasswordGeneratorStrategy = strategies.PasswordGeneratorStrategy; +const CryptoServiceRandomizer = engine.CryptoServiceRandomizer; +const DefaultGeneratorService = services.DefaultGeneratorService; + +export function legacyPasswordGenerationServiceFactory( + encryptService: EncryptService, + cryptoService: CryptoService, + policyService: PolicyService, + accountService: AccountService, + stateProvider: StateProvider, +): PasswordGenerationServiceAbstraction { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + const passwords = new DefaultGeneratorService( + new PasswordGeneratorStrategy(randomizer, stateProvider), + policyService, + ); + + const passphrases = new DefaultGeneratorService( + new PassphraseGeneratorStrategy(randomizer, stateProvider), + policyService, + ); + + const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); + + const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider); + + return new LegacyPasswordGenerationService( + accountService, + navigation, + passwords, + passphrases, + history, + ); +} diff --git a/libs/tools/generator/extensions/src/legacy-password/index.ts b/libs/tools/generator/extensions/src/legacy-password/index.ts new file mode 100644 index 0000000000..61fbc73456 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/index.ts @@ -0,0 +1,3 @@ +export * from "./password-generation.service.abstraction"; +export * from "./factory"; +export * from "./password-generator-options"; diff --git a/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.spec.ts b/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.spec.ts new file mode 100644 index 0000000000..ff70e8b295 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.spec.ts @@ -0,0 +1,562 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; +import { + GeneratorService, + DefaultPassphraseGenerationOptions, + DefaultPasswordGenerationOptions, + DisabledPassphraseGeneratorPolicy, + DisabledPasswordGeneratorPolicy, + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PasswordGenerationOptions, + PasswordGeneratorPolicy, + policies, +} from "@bitwarden/generator-core"; + +import { mockAccountServiceWith } from "../../../../../common/spec"; +import { GeneratedCredential, GeneratorHistoryService, GeneratedPasswordHistory } from "../history"; +import { + GeneratorNavigationService, + DefaultGeneratorNavigation, + GeneratorNavigation, + GeneratorNavigationEvaluator, + GeneratorNavigationPolicy, +} from "../navigation"; + +import { LegacyPasswordGenerationService } from "./legacy-password-generation.service"; +import { PasswordGeneratorOptions } from "./password-generator-options"; + +const SomeUser = "some user" as UserId; +const PassphraseGeneratorOptionsEvaluator = policies.PassphraseGeneratorOptionsEvaluator; +const PasswordGeneratorOptionsEvaluator = policies.PasswordGeneratorOptionsEvaluator; + +function createPassphraseGenerator( + options: PassphraseGenerationOptions = {}, + policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy, +) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new PassphraseGeneratorOptionsEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultPassphraseGenerationOptions); + }, + saveOptions(userId, options) { + savedOptions = options; + return Promise.resolve(); + }, + }); + + return generator; +} + +function createPasswordGenerator( + options: PasswordGenerationOptions = {}, + policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy, +) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new PasswordGeneratorOptionsEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultPasswordGenerationOptions); + }, + saveOptions(userId, options) { + savedOptions = options; + return Promise.resolve(); + }, + }); + + return generator; +} + +function createNavigationGenerator( + options: GeneratorNavigation = {}, + policy: GeneratorNavigationPolicy = {}, +) { + let savedOptions = options; + const generator = mock({ + evaluator$(id: UserId) { + const evaluator = new GeneratorNavigationEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultGeneratorNavigation); + }, + saveOptions: jest.fn((userId, options) => { + savedOptions = options; + return Promise.resolve(); + }), + }); + + return generator; +} + +describe("LegacyPasswordGenerationService", () => { + // NOTE: in all tests, `null` constructor arguments are not used by the test. + // They're set to `null` to avoid setting up unnecessary mocks. + + describe("generatePassword", () => { + it("invokes the inner password generator to generate passwords", async () => { + const innerPassword = createPasswordGenerator(); + const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null); + const options = { type: "password" } as PasswordGeneratorOptions; + + await generator.generatePassword(options); + + expect(innerPassword.generate).toHaveBeenCalledWith(options); + }); + + it("invokes the inner passphrase generator to generate passphrases", async () => { + const innerPassphrase = createPassphraseGenerator(); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); + const options = { type: "passphrase" } as PasswordGeneratorOptions; + + await generator.generatePassword(options); + + expect(innerPassphrase.generate).toHaveBeenCalledWith(options); + }); + }); + + describe("generatePassphrase", () => { + it("invokes the inner passphrase generator", async () => { + const innerPassphrase = createPassphraseGenerator(); + const generator = new LegacyPasswordGenerationService( + null, + null, + null, + innerPassphrase, + null, + ); + const options = {} as PasswordGeneratorOptions; + + await generator.generatePassphrase(options); + + expect(innerPassphrase.generate).toHaveBeenCalledWith(options); + }); + }); + + describe("getOptions", () => { + it("combines options from its inner services", async () => { + const innerPassword = createPasswordGenerator({ + length: 29, + minLength: 20, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 2, + number: true, + minNumber: 3, + special: false, + minSpecial: 0, + }); + const innerPassphrase = createPassphraseGenerator({ + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }); + const navigation = createNavigationGenerator({ + type: "passphrase", + username: "word", + forwarder: "simplelogin", + }); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [result] = await generator.getOptions(); + + expect(result).toEqual({ + type: "passphrase", + length: 29, + minLength: 5, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 0, + number: true, + minNumber: 3, + special: false, + minSpecial: 0, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + policyUpdated: true, + }); + }); + + it("sets default options when an inner service lacks a value", async () => { + const innerPassword = createPasswordGenerator(null); + const innerPassphrase = createPassphraseGenerator(null); + const navigation = createNavigationGenerator(null); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [result] = await generator.getOptions(); + + expect(result).toEqual({ + type: DefaultGeneratorNavigation.type, + ...DefaultPassphraseGenerationOptions, + ...DefaultPasswordGenerationOptions, + minLowercase: 1, + minUppercase: 1, + policyUpdated: true, + }); + }); + + it("combines policies from its inner services", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + }, + ); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator( + {}, + { + defaultType: "password", + }, + ); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [, policy] = await generator.getOptions(); + + expect(policy).toEqual({ + defaultType: "password", + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }); + }); + }); + + describe("enforcePasswordGeneratorPoliciesOnOptions", () => { + it("returns its options parameter with password policy applied", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 15, + numberCount: 5, + specialCount: 5, + useUppercase: true, + useLowercase: true, + useNumbers: true, + useSpecial: true, + }, + ); + const innerPassphrase = createPassphraseGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(); + const options = { + type: "password" as const, + }; + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); + + expect(result).toBe(options); + expect(result).toMatchObject({ + length: 15, + minLength: 15, + minLowercase: 1, + minNumber: 5, + minUppercase: 1, + minSpecial: 5, + uppercase: true, + lowercase: true, + number: true, + special: true, + }); + }); + + it("returns its options parameter with passphrase policy applied", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: true, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(); + const options = { + type: "passphrase" as const, + }; + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options); + + expect(result).toBe(options); + expect(result).toMatchObject({ + numWords: 5, + capitalize: true, + includeNumber: true, + }); + }); + + it("returns the applied policy", async () => { + const innerPassword = createPasswordGenerator( + {}, + { + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + }, + ); + const innerPassphrase = createPassphraseGenerator( + {}, + { + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }, + ); + const accountService = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator( + {}, + { + defaultType: "password", + }, + ); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + + const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({}); + + expect(policy).toEqual({ + defaultType: "password", + minLength: 20, + numberCount: 10, + specialCount: 11, + useUppercase: true, + useLowercase: false, + useNumbers: true, + useSpecial: false, + minNumberWords: 5, + capitalize: true, + includeNumber: false, + }); + }); + }); + + describe("saveOptions", () => { + it("loads saved password options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + const options = { + type: "password" as const, + length: 29, + minLength: 5, + ambiguous: false, + uppercase: true, + minUppercase: 1, + lowercase: false, + minLowercase: 0, + number: true, + minNumber: 3, + special: false, + minSpecial: 0, + }; + await generator.saveOptions(options); + + const [result] = await generator.getOptions(); + + expect(result).toMatchObject(options); + }); + + it("loads saved passphrase options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + const options = { + type: "passphrase" as const, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }; + await generator.saveOptions(options); + + const [result] = await generator.getOptions(); + + expect(result).toMatchObject(options); + }); + + it("preserves saved navigation options", async () => { + const innerPassword = createPasswordGenerator(); + const innerPassphrase = createPassphraseGenerator(); + const navigation = createNavigationGenerator({ + type: "password", + username: "forwarded", + forwarder: "firefoxrelay", + }); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + navigation, + innerPassword, + innerPassphrase, + null, + ); + const options = { + type: "passphrase" as const, + numWords: 10, + wordSeparator: "-", + capitalize: true, + includeNumber: false, + }; + + await generator.saveOptions(options); + + expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { + type: "passphrase", + username: "forwarded", + forwarder: "firefoxrelay", + }); + }); + }); + + describe("getHistory", () => { + it("gets the active user's history from the history service", async () => { + const history = mock(); + history.credentials$.mockReturnValue( + of([new GeneratedCredential("foo", "password", new Date(100))]), + ); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + const result = await generator.getHistory(); + + expect(history.credentials$).toHaveBeenCalledWith(SomeUser); + expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]); + }); + }); + + describe("addHistory", () => { + it("adds a history item as a password credential", async () => { + const history = mock(); + const accountService = mockAccountServiceWith(SomeUser); + const generator = new LegacyPasswordGenerationService( + accountService, + null, + null, + null, + history, + ); + + await generator.addHistory("foo"); + + expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password"); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.ts b/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.ts new file mode 100644 index 0000000000..e0261584f5 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/legacy-password-generation.service.ts @@ -0,0 +1,381 @@ +import { + concatMap, + zip, + map, + firstValueFrom, + combineLatest, + pairwise, + of, + concat, + Observable, + filter, + timeout, +} from "rxjs"; + +import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + GeneratorService, + PassphraseGenerationOptions, + PassphraseGeneratorPolicy, + PasswordGenerationOptions, + PasswordGeneratorPolicy, + PolicyEvaluator, +} from "@bitwarden/generator-core"; + +import { GeneratedCredential, GeneratorHistoryService, GeneratedPasswordHistory } from "../history"; +import { + GeneratorNavigationService, + GeneratorNavigation, + GeneratorNavigationPolicy, +} from "../navigation"; + +import { PasswordGenerationServiceAbstraction } from "./password-generation.service.abstraction"; +import { PasswordGeneratorOptions } from "./password-generator-options"; + +type MappedOptions = { + generator: GeneratorNavigation; + password: PasswordGenerationOptions; + passphrase: PassphraseGenerationOptions; + policyUpdated: boolean; +}; + +/** Adapts the generator 2.0 design to 1.0 angular services. */ +export class LegacyPasswordGenerationService implements PasswordGenerationServiceAbstraction { + constructor( + private readonly accountService: AccountService, + private readonly navigation: GeneratorNavigationService, + private readonly passwords: GeneratorService< + PasswordGenerationOptions, + PasswordGeneratorPolicy + >, + private readonly passphrases: GeneratorService< + PassphraseGenerationOptions, + PassphraseGeneratorPolicy + >, + private readonly history: GeneratorHistoryService, + ) {} + + generatePassword(options: PasswordGeneratorOptions) { + if (options.type === "password") { + return this.passwords.generate(options); + } else { + return this.passphrases.generate(options); + } + } + + generatePassphrase(options: PasswordGeneratorOptions) { + return this.passphrases.generate(options); + } + + private getRawOptions$() { + // give the typechecker a nudge to avoid "implicit any" errors + type RawOptionsIntermediateType = [ + PasswordGenerationOptions, + PasswordGenerationOptions, + [PolicyEvaluator, number], + PassphraseGenerationOptions, + PassphraseGenerationOptions, + [PolicyEvaluator, number], + GeneratorNavigation, + GeneratorNavigation, + [PolicyEvaluator, number], + ]; + + function withSequenceNumber(observable$: Observable) { + return observable$.pipe(map((evaluator, i) => [evaluator, i] as const)); + } + + // initial array ensures that destructuring never fails; sequence numbers + // set to `-1` so that the first update reflects that the policy changed from + // "unknown" to "whatever was provided by the service". This needs to be called + // each time the active user changes or the `concat` will block. + function initial$() { + const initial: RawOptionsIntermediateType = [ + null, + null, + [null, -1], + null, + null, + [null, -1], + null, + null, + [null, -1], + ]; + + return of(initial); + } + + function intermediatePairsToRawOptions([previous, current]: [ + RawOptionsIntermediateType, + RawOptionsIntermediateType, + ]) { + const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] = + previous; + const [ + passwordOptions, + passwordDefaults, + [passwordEvaluator, passwordCurrent], + passphraseOptions, + passphraseDefaults, + [passphraseEvaluator, passphraseCurrent], + generatorOptions, + generatorDefaults, + [generatorEvaluator, generatorCurrent], + ] = current; + + // when any of the sequence numbers change, the emission occurs as the result of + // a policy update + const policyEmitted = + passwordPrevious < passwordCurrent || + passphrasePrevious < passphraseCurrent || + generatorPrevious < generatorCurrent; + + const result = [ + passwordOptions, + passwordDefaults, + passwordEvaluator, + passphraseOptions, + passphraseDefaults, + passphraseEvaluator, + generatorOptions, + generatorDefaults, + generatorEvaluator, + policyEmitted, + ] as const; + + return result; + } + + // look upon my works, ye mighty, and despair! + const rawOptions$ = this.accountService.activeAccount$.pipe( + concatMap((activeUser) => + concat( + initial$(), + combineLatest([ + this.passwords.options$(activeUser.id), + this.passwords.defaults$(activeUser.id), + withSequenceNumber(this.passwords.evaluator$(activeUser.id)), + this.passphrases.options$(activeUser.id), + this.passphrases.defaults$(activeUser.id), + withSequenceNumber(this.passphrases.evaluator$(activeUser.id)), + this.navigation.options$(activeUser.id), + this.navigation.defaults$(activeUser.id), + withSequenceNumber(this.navigation.evaluator$(activeUser.id)), + ]), + ), + ), + pairwise(), + map(intermediatePairsToRawOptions), + ); + + return rawOptions$; + } + + getOptions$() { + const options$ = this.getRawOptions$().pipe( + map( + ([ + passwordOptions, + passwordDefaults, + passwordEvaluator, + passphraseOptions, + passphraseDefaults, + passphraseEvaluator, + generatorOptions, + generatorDefaults, + generatorEvaluator, + policyUpdated, + ]) => { + const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy( + passwordOptions ?? passwordDefaults, + ); + const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy( + passphraseOptions ?? passphraseDefaults, + ); + const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy( + generatorOptions ?? generatorDefaults, + ); + + const options = this.toPasswordGeneratorOptions({ + password: passwordEvaluator.sanitize(passwordOptionsWithPolicy), + passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy), + generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy), + policyUpdated, + }); + + const policy = Object.assign( + new PasswordGeneratorPolicyOptions(), + passwordEvaluator.policy, + passphraseEvaluator.policy, + generatorEvaluator.policy, + ); + + return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; + }, + ), + ); + + return options$; + } + + async getOptions() { + return await firstValueFrom(this.getOptions$()); + } + + async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) { + const options$ = this.accountService.activeAccount$.pipe( + concatMap((activeUser) => + zip( + this.passwords.evaluator$(activeUser.id), + this.passphrases.evaluator$(activeUser.id), + this.navigation.evaluator$(activeUser.id), + ), + ), + map(([passwordEvaluator, passphraseEvaluator, navigationEvaluator]) => { + const policy = Object.assign( + new PasswordGeneratorPolicyOptions(), + passwordEvaluator.policy, + passphraseEvaluator.policy, + navigationEvaluator.policy, + ); + + const navigationApplied = navigationEvaluator.applyPolicy(options); + const navigationSanitized = { + ...options, + ...navigationEvaluator.sanitize(navigationApplied), + }; + if (options.type === "password") { + const applied = passwordEvaluator.applyPolicy(navigationSanitized); + const sanitized = passwordEvaluator.sanitize(applied); + return [sanitized, policy]; + } else { + const applied = passphraseEvaluator.applyPolicy(navigationSanitized); + const sanitized = passphraseEvaluator.sanitize(applied); + return [sanitized, policy]; + } + }), + ); + + const [sanitized, policy] = await firstValueFrom(options$); + return [ + // callers assume this function updates the options parameter + Object.assign(options, sanitized), + policy, + ] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]; + } + + async saveOptions(options: PasswordGeneratorOptions) { + const stored = this.toStoredOptions(options); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + // generator settings needs to preserve whether password or passphrase is selected, + // so `navigationOptions` is mutated. + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); + navigationOptions = Object.assign(navigationOptions, stored.generator); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); + + // overwrite all other settings with latest values + await this.passwords.saveOptions(activeAccount.id, stored.password); + await this.passphrases.saveOptions(activeAccount.id, stored.passphrase); + } + + private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions { + return { + generator: { + type: options.type, + }, + password: { + length: options.length, + minLength: options.minLength, + ambiguous: options.ambiguous, + uppercase: options.uppercase, + minUppercase: options.minUppercase, + lowercase: options.lowercase, + minLowercase: options.minLowercase, + number: options.number, + minNumber: options.minNumber, + special: options.special, + minSpecial: options.minSpecial, + }, + passphrase: { + numWords: options.numWords, + wordSeparator: options.wordSeparator, + capitalize: options.capitalize, + includeNumber: options.includeNumber, + }, + policyUpdated: false, + }; + } + + private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions { + return { + type: options.generator.type, + length: options.password.length, + minLength: options.password.minLength, + ambiguous: options.password.ambiguous, + uppercase: options.password.uppercase, + minUppercase: options.password.minUppercase, + lowercase: options.password.lowercase, + minLowercase: options.password.minLowercase, + number: options.password.number, + minNumber: options.password.minNumber, + special: options.password.special, + minSpecial: options.password.minSpecial, + numWords: options.passphrase.numWords, + wordSeparator: options.passphrase.wordSeparator, + capitalize: options.passphrase.capitalize, + includeNumber: options.passphrase.includeNumber, + policyUpdated: options.policyUpdated, + }; + } + + getHistory() { + const history = this.accountService.activeAccount$.pipe( + concatMap((account) => this.history.credentials$(account.id)), + timeout({ + // timeout after 1 second + each: 1000, + with() { + return []; + }, + }), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history); + } + + async addHistory(password: string) { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (account?.id) { + // legacy service doesn't distinguish credential types + await this.history.track(account.id, password, "password"); + } + } + + clear() { + const history$ = this.accountService.activeAccount$.pipe( + filter((account) => !!account?.id), + concatMap((account) => this.history.clear(account.id)), + timeout({ + // timeout after 1 second + each: 1000, + with() { + return []; + }, + }), + map((history) => history.map(toGeneratedPasswordHistory)), + ); + + return firstValueFrom(history$); + } +} + +function toGeneratedPasswordHistory(value: GeneratedCredential) { + return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf()); +} diff --git a/libs/tools/generator/extensions/src/legacy-password/password-generation.service.abstraction.ts b/libs/tools/generator/extensions/src/legacy-password/password-generation.service.abstraction.ts new file mode 100644 index 0000000000..3bc1582824 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/password-generation.service.abstraction.ts @@ -0,0 +1,22 @@ +import { Observable } from "rxjs"; + +import { PasswordGeneratorPolicyOptions } from "@bitwarden/common/admin-console/models/domain/password-generator-policy-options"; + +import { GeneratedPasswordHistory } from "../history"; + +import { PasswordGeneratorOptions } from "./password-generator-options"; + +/** @deprecated Use {@link GeneratorService} with a password or passphrase {@link GeneratorStrategy} instead. */ +export abstract class PasswordGenerationServiceAbstraction { + generatePassword: (options: PasswordGeneratorOptions) => Promise; + generatePassphrase: (options: PasswordGeneratorOptions) => Promise; + getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; + getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; + enforcePasswordGeneratorPoliciesOnOptions: ( + options: PasswordGeneratorOptions, + ) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>; + saveOptions: (options: PasswordGeneratorOptions) => Promise; + getHistory: () => Promise; + addHistory: (password: string) => Promise; + clear: (userId?: string) => Promise; +} diff --git a/libs/tools/generator/extensions/src/legacy-password/password-generator-options.ts b/libs/tools/generator/extensions/src/legacy-password/password-generator-options.ts new file mode 100644 index 0000000000..762a1ce3ae --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-password/password-generator-options.ts @@ -0,0 +1,10 @@ +import { PassphraseGenerationOptions, PasswordGenerationOptions } from "@bitwarden/generator-core"; + +import { GeneratorNavigation } from "../navigation"; + +/** Request format for credential generation. + * This type includes all properties suitable for reactive data binding. + */ +export type PasswordGeneratorOptions = PasswordGenerationOptions & + PassphraseGenerationOptions & + GeneratorNavigation & { policyUpdated?: boolean }; diff --git a/libs/tools/generator/extensions/src/legacy-username/factory.ts b/libs/tools/generator/extensions/src/legacy-username/factory.ts new file mode 100644 index 0000000000..a73699c073 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/factory.ts @@ -0,0 +1,110 @@ +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +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 { StateProvider } from "@bitwarden/common/platform/state"; +import { engine, services, strategies } from "@bitwarden/generator-core"; + +import { DefaultGeneratorNavigationService } from "../navigation"; + +import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; +import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; + +const DefaultGeneratorService = services.DefaultGeneratorService; +const CryptoServiceRandomizer = engine.CryptoServiceRandomizer; +const CatchallGeneratorStrategy = strategies.CatchallGeneratorStrategy; +const SubaddressGeneratorStrategy = strategies.SubaddressGeneratorStrategy; +const EffUsernameGeneratorStrategy = strategies.EffUsernameGeneratorStrategy; +const AddyIoForwarder = strategies.AddyIoForwarder; +const DuckDuckGoForwarder = strategies.DuckDuckGoForwarder; +const FastmailForwarder = strategies.FastmailForwarder; +const FirefoxRelayForwarder = strategies.FirefoxRelayForwarder; +const ForwardEmailForwarder = strategies.ForwardEmailForwarder; +const SimpleLoginForwarder = strategies.SimpleLoginForwarder; + +export function legacyUsernameGenerationServiceFactory( + apiService: ApiService, + i18nService: I18nService, + cryptoService: CryptoService, + encryptService: EncryptService, + policyService: PolicyService, + accountService: AccountService, + stateProvider: StateProvider, +): UsernameGenerationServiceAbstraction { + const randomizer = new CryptoServiceRandomizer(cryptoService); + + const effUsername = new DefaultGeneratorService( + new EffUsernameGeneratorStrategy(randomizer, stateProvider), + policyService, + ); + + const subaddress = new DefaultGeneratorService( + new SubaddressGeneratorStrategy(randomizer, stateProvider), + policyService, + ); + + const catchall = new DefaultGeneratorService( + new CatchallGeneratorStrategy(randomizer, stateProvider), + policyService, + ); + + const addyIo = new DefaultGeneratorService( + new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const duckDuckGo = new DefaultGeneratorService( + new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const fastmail = new DefaultGeneratorService( + new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const firefoxRelay = new DefaultGeneratorService( + new FirefoxRelayForwarder( + apiService, + i18nService, + encryptService, + cryptoService, + stateProvider, + ), + policyService, + ); + + const forwardEmail = new DefaultGeneratorService( + new ForwardEmailForwarder( + apiService, + i18nService, + encryptService, + cryptoService, + stateProvider, + ), + policyService, + ); + + const simpleLogin = new DefaultGeneratorService( + new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider), + policyService, + ); + + const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService); + + return new LegacyUsernameGenerationService( + accountService, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); +} diff --git a/libs/tools/generator/extensions/src/legacy-username/index.ts b/libs/tools/generator/extensions/src/legacy-username/index.ts new file mode 100644 index 0000000000..462b49d7cd --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/index.ts @@ -0,0 +1,3 @@ +export * from "./username-generation.service.abstraction"; +export * from "./factory"; +export * from "./username-generation-options"; diff --git a/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.spec.ts b/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.spec.ts new file mode 100644 index 0000000000..6b66c1d880 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.spec.ts @@ -0,0 +1,749 @@ +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; +import { + ApiOptions, + EmailDomainOptions, + EmailPrefixOptions, + SelfHostedApiOptions, + GeneratorService, + NoPolicy, + CatchallGenerationOptions, + DefaultCatchallOptions, + DefaultEffUsernameOptions, + EffUsernameGenerationOptions, + DefaultAddyIoOptions, + DefaultDuckDuckGoOptions, + DefaultFastmailOptions, + DefaultFirefoxRelayOptions, + DefaultForwardEmailOptions, + DefaultSimpleLoginOptions, + Forwarders, + DefaultSubaddressOptions, + SubaddressGenerationOptions, + policies, +} from "@bitwarden/generator-core"; + +import { mockAccountServiceWith } from "../../../../../common/spec"; +import { + GeneratorNavigationPolicy, + GeneratorNavigationEvaluator, + DefaultGeneratorNavigation, + GeneratorNavigation, + GeneratorNavigationService, +} from "../navigation"; + +import { LegacyUsernameGenerationService } from "./legacy-username-generation.service"; +import { UsernameGeneratorOptions } from "./username-generation-options"; + +const DefaultPolicyEvaluator = policies.DefaultPolicyEvaluator; + +const SomeUser = "userId" as UserId; + +function createGenerator(options: Options, defaults: Options) { + let savedOptions = options; + const generator = mock>({ + evaluator$(id: UserId) { + const evaluator = new DefaultPolicyEvaluator(); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(defaults); + }, + saveOptions: jest.fn((userId, options) => { + savedOptions = options; + return Promise.resolve(); + }), + }); + + return generator; +} + +function createNavigationGenerator( + options: GeneratorNavigation = {}, + policy: GeneratorNavigationPolicy = {}, +) { + let savedOptions = options; + const generator = mock({ + evaluator$(id: UserId) { + const evaluator = new GeneratorNavigationEvaluator(policy); + return of(evaluator); + }, + options$(id: UserId) { + return of(savedOptions); + }, + defaults$(id: UserId) { + return of(DefaultGeneratorNavigation); + }, + saveOptions: jest.fn((userId, options) => { + savedOptions = options; + return Promise.resolve(); + }), + }); + + return generator; +} + +describe("LegacyUsernameGenerationService", () => { + // NOTE: in all tests, `null` constructor arguments are not used by the test. + // They're set to `null` to avoid setting up unnecessary mocks. + describe("generateUserName", () => { + it("should generate a catchall username", async () => { + const options = { type: "catchall" } as UsernameGeneratorOptions; + const catchall = createGenerator(null, null); + catchall.generate.mockResolvedValue("catchall@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + catchall, + null, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(catchall.generate).toHaveBeenCalledWith(options); + expect(result).toBe("catchall@example.com"); + }); + + it("should generate an EFF word username", async () => { + const options = { type: "word" } as UsernameGeneratorOptions; + const effWord = createGenerator(null, null); + effWord.generate.mockResolvedValue("eff word"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + effWord, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(effWord.generate).toHaveBeenCalledWith(options); + expect(result).toBe("eff word"); + }); + + it("should generate a subaddress username", async () => { + const options = { type: "subaddress" } as UsernameGeneratorOptions; + const subaddress = createGenerator(null, null); + subaddress.generate.mockResolvedValue("subaddress@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + subaddress, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(subaddress.generate).toHaveBeenCalledWith(options); + expect(result).toBe("subaddress@example.com"); + }); + + it("should generate a forwarder username", async () => { + // set up an arbitrary forwarder for the username test; all forwarders tested in their own tests + const options = { + type: "forwarded", + forwardedService: Forwarders.AddyIo.id, + } as UsernameGeneratorOptions; + const addyIo = createGenerator(null, null); + addyIo.generate.mockResolvedValue("addyio@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + addyIo, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateUsername(options); + + expect(addyIo.generate).toHaveBeenCalledWith({}); + expect(result).toBe("addyio@example.com"); + }); + }); + + describe("generateCatchall", () => { + it("should generate a catchall username", async () => { + const options = { type: "catchall" } as UsernameGeneratorOptions; + const catchall = createGenerator(null, null); + catchall.generate.mockResolvedValue("catchall@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + catchall, + null, + null, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateCatchall(options); + + expect(catchall.generate).toHaveBeenCalledWith(options); + expect(result).toBe("catchall@example.com"); + }); + }); + + describe("generateSubaddress", () => { + it("should generate a subaddress username", async () => { + const options = { type: "subaddress" } as UsernameGeneratorOptions; + const subaddress = createGenerator(null, null); + subaddress.generate.mockResolvedValue("subaddress@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + subaddress, + null, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateSubaddress(options); + + expect(subaddress.generate).toHaveBeenCalledWith(options); + expect(result).toBe("subaddress@example.com"); + }); + }); + + describe("generateForwarded", () => { + it("should generate a AddyIo username", async () => { + const options = { + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "token", + forwardedAnonAddyBaseUrl: "https://example.com", + forwardedAnonAddyDomain: "example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const addyIo = createGenerator(null, null); + addyIo.generate.mockResolvedValue("addyio@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + addyIo, + null, + null, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(addyIo.generate).toHaveBeenCalledWith({ + token: "token", + baseUrl: "https://example.com", + domain: "example.com", + website: "example.com", + }); + expect(result).toBe("addyio@example.com"); + }); + + it("should generate a DuckDuckGo username", async () => { + const options = { + forwardedService: Forwarders.DuckDuckGo.id, + forwardedDuckDuckGoToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const duckDuckGo = createGenerator(null, null); + duckDuckGo.generate.mockResolvedValue("ddg@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + duckDuckGo, + null, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(duckDuckGo.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("ddg@example.com"); + }); + + it("should generate a Fastmail username", async () => { + const options = { + forwardedService: Forwarders.Fastmail.id, + forwardedFastmailApiToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const fastmail = createGenerator(null, null); + fastmail.generate.mockResolvedValue("fastmail@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + fastmail, + null, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(fastmail.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("fastmail@example.com"); + }); + + it("should generate a FirefoxRelay username", async () => { + const options = { + forwardedService: Forwarders.FirefoxRelay.id, + forwardedFirefoxApiToken: "token", + website: "example.com", + } as UsernameGeneratorOptions; + const firefoxRelay = createGenerator(null, null); + firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + firefoxRelay, + null, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(firefoxRelay.generate).toHaveBeenCalledWith({ + token: "token", + website: "example.com", + }); + expect(result).toBe("firefoxrelay@example.com"); + }); + + it("should generate a ForwardEmail username", async () => { + const options = { + forwardedService: Forwarders.ForwardEmail.id, + forwardedForwardEmailApiToken: "token", + forwardedForwardEmailDomain: "example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const forwardEmail = createGenerator(null, null); + forwardEmail.generate.mockResolvedValue("forwardemail@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + forwardEmail, + null, + ); + + const result = await generator.generateForwarded(options); + + expect(forwardEmail.generate).toHaveBeenCalledWith({ + token: "token", + domain: "example.com", + website: "example.com", + }); + expect(result).toBe("forwardemail@example.com"); + }); + + it("should generate a SimpleLogin username", async () => { + const options = { + forwardedService: Forwarders.SimpleLogin.id, + forwardedSimpleLoginApiKey: "token", + forwardedSimpleLoginBaseUrl: "https://example.com", + website: "example.com", + } as UsernameGeneratorOptions; + const simpleLogin = createGenerator(null, null); + simpleLogin.generate.mockResolvedValue("simplelogin@example.com"); + const generator = new LegacyUsernameGenerationService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + simpleLogin, + ); + + const result = await generator.generateForwarded(options); + + expect(simpleLogin.generate).toHaveBeenCalledWith({ + token: "token", + baseUrl: "https://example.com", + website: "example.com", + }); + expect(result).toBe("simplelogin@example.com"); + }); + }); + + describe("getOptions", () => { + it("combines options from its inner generators", async () => { + const account = mockAccountServiceWith(SomeUser); + + const navigation = createNavigationGenerator({ + type: "username", + username: "catchall", + forwarder: Forwarders.AddyIo.id, + }); + + const catchall = createGenerator( + { + catchallDomain: "example.com", + catchallType: "random", + website: null, + }, + null, + ); + + const effUsername = createGenerator( + { + wordCapitalize: true, + wordIncludeNumber: false, + website: null, + }, + null, + ); + + const subaddress = createGenerator( + { + subaddressType: "random", + subaddressEmail: "foo@example.com", + website: null, + }, + null, + ); + + const addyIo = createGenerator( + { + token: "addyIoToken", + domain: "addyio.example.com", + baseUrl: "https://addyio.api.example.com", + website: null, + }, + null, + ); + + const duckDuckGo = createGenerator( + { + token: "ddgToken", + website: null, + }, + null, + ); + + const fastmail = createGenerator( + { + token: "fastmailToken", + domain: "fastmail.example.com", + prefix: "foo", + website: null, + }, + null, + ); + + const firefoxRelay = createGenerator( + { + token: "firefoxToken", + website: null, + }, + null, + ); + + const forwardEmail = createGenerator( + { + token: "forwardEmailToken", + domain: "example.com", + website: null, + }, + null, + ); + + const simpleLogin = createGenerator( + { + token: "simpleLoginToken", + baseUrl: "https://simplelogin.api.example.com", + website: null, + }, + null, + ); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + const result = await generator.getOptions(); + + expect(result).toEqual({ + type: "catchall", + wordCapitalize: true, + wordIncludeNumber: false, + subaddressType: "random", + subaddressEmail: "foo@example.com", + catchallType: "random", + catchallDomain: "example.com", + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "addyIoToken", + forwardedAnonAddyDomain: "addyio.example.com", + forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", + forwardedDuckDuckGoToken: "ddgToken", + forwardedFirefoxApiToken: "firefoxToken", + forwardedFastmailApiToken: "fastmailToken", + forwardedForwardEmailApiToken: "forwardEmailToken", + forwardedForwardEmailDomain: "example.com", + forwardedSimpleLoginApiKey: "simpleLoginToken", + forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", + }); + }); + + it("sets default options when an inner service lacks a value", async () => { + const account = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator(null); + const catchall = createGenerator(null, DefaultCatchallOptions); + const effUsername = createGenerator( + null, + DefaultEffUsernameOptions, + ); + const subaddress = createGenerator( + null, + DefaultSubaddressOptions, + ); + const addyIo = createGenerator( + null, + DefaultAddyIoOptions, + ); + const duckDuckGo = createGenerator(null, DefaultDuckDuckGoOptions); + const fastmail = createGenerator( + null, + DefaultFastmailOptions, + ); + const firefoxRelay = createGenerator(null, DefaultFirefoxRelayOptions); + const forwardEmail = createGenerator( + null, + DefaultForwardEmailOptions, + ); + const simpleLogin = createGenerator(null, DefaultSimpleLoginOptions); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + const result = await generator.getOptions(); + + expect(result).toEqual({ + type: DefaultGeneratorNavigation.username, + catchallType: DefaultCatchallOptions.catchallType, + catchallDomain: DefaultCatchallOptions.catchallDomain, + wordCapitalize: DefaultEffUsernameOptions.wordCapitalize, + wordIncludeNumber: DefaultEffUsernameOptions.wordIncludeNumber, + subaddressType: DefaultSubaddressOptions.subaddressType, + subaddressEmail: DefaultSubaddressOptions.subaddressEmail, + forwardedService: DefaultGeneratorNavigation.forwarder, + forwardedAnonAddyApiToken: DefaultAddyIoOptions.token, + forwardedAnonAddyDomain: DefaultAddyIoOptions.domain, + forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl, + forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token, + forwardedFastmailApiToken: DefaultFastmailOptions.token, + forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token, + forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token, + forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain, + forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token, + forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl, + }); + }); + }); + + describe("saveOptions", () => { + it("saves option sets to its inner generators", async () => { + const account = mockAccountServiceWith(SomeUser); + const navigation = createNavigationGenerator({ type: "password" }); + const catchall = createGenerator(null, null); + const effUsername = createGenerator(null, null); + const subaddress = createGenerator(null, null); + const addyIo = createGenerator(null, null); + const duckDuckGo = createGenerator(null, null); + const fastmail = createGenerator(null, null); + const firefoxRelay = createGenerator(null, null); + const forwardEmail = createGenerator(null, null); + const simpleLogin = createGenerator(null, null); + + const generator = new LegacyUsernameGenerationService( + account, + navigation, + catchall, + effUsername, + subaddress, + addyIo, + duckDuckGo, + fastmail, + firefoxRelay, + forwardEmail, + simpleLogin, + ); + + await generator.saveOptions({ + type: "catchall", + wordCapitalize: true, + wordIncludeNumber: false, + subaddressType: "random", + subaddressEmail: "foo@example.com", + catchallType: "random", + catchallDomain: "example.com", + forwardedService: Forwarders.AddyIo.id, + forwardedAnonAddyApiToken: "addyIoToken", + forwardedAnonAddyDomain: "addyio.example.com", + forwardedAnonAddyBaseUrl: "https://addyio.api.example.com", + forwardedDuckDuckGoToken: "ddgToken", + forwardedFirefoxApiToken: "firefoxToken", + forwardedFastmailApiToken: "fastmailToken", + forwardedForwardEmailApiToken: "forwardEmailToken", + forwardedForwardEmailDomain: "example.com", + forwardedSimpleLoginApiKey: "simpleLoginToken", + forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com", + website: null, + }); + + expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, { + type: "password", + username: "catchall", + forwarder: Forwarders.AddyIo.id, + }); + + expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, { + catchallDomain: "example.com", + catchallType: "random", + website: null, + }); + + expect(effUsername.saveOptions).toHaveBeenCalledWith(SomeUser, { + wordCapitalize: true, + wordIncludeNumber: false, + website: null, + }); + + expect(subaddress.saveOptions).toHaveBeenCalledWith(SomeUser, { + subaddressType: "random", + subaddressEmail: "foo@example.com", + website: null, + }); + + expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "addyIoToken", + domain: "addyio.example.com", + baseUrl: "https://addyio.api.example.com", + website: null, + }); + + expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "ddgToken", + website: null, + }); + + expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "fastmailToken", + website: null, + }); + + expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "firefoxToken", + website: null, + }); + + expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "forwardEmailToken", + domain: "example.com", + website: null, + }); + + expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, { + token: "simpleLoginToken", + baseUrl: "https://simplelogin.api.example.com", + website: null, + }); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.ts b/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.ts new file mode 100644 index 0000000000..5184366328 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/legacy-username-generation.service.ts @@ -0,0 +1,286 @@ +import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + ApiOptions, + EmailDomainOptions, + EmailPrefixOptions, + RequestOptions, + SelfHostedApiOptions, + NoPolicy, + GeneratorService, + CatchallGenerationOptions, + EffUsernameGenerationOptions, + Forwarders, + SubaddressGenerationOptions, +} from "@bitwarden/generator-core"; + +import { GeneratorNavigationService, GeneratorNavigation } from "../navigation"; + +import { UsernameGeneratorOptions } from "./username-generation-options"; +import { UsernameGenerationServiceAbstraction } from "./username-generation.service.abstraction"; + +type MappedOptions = { + generator: GeneratorNavigation; + algorithms: { + catchall: CatchallGenerationOptions; + effUsername: EffUsernameGenerationOptions; + subaddress: SubaddressGenerationOptions; + }; + forwarders: { + addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions; + duckDuckGo: ApiOptions & RequestOptions; + fastmail: ApiOptions & EmailPrefixOptions & RequestOptions; + firefoxRelay: ApiOptions & RequestOptions; + forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions; + simpleLogin: SelfHostedApiOptions & RequestOptions; + }; +}; + +/** Adapts the generator 2.0 design to 1.0 angular services. */ +export class LegacyUsernameGenerationService implements UsernameGenerationServiceAbstraction { + constructor( + private readonly accountService: AccountService, + private readonly navigation: GeneratorNavigationService, + private readonly catchall: GeneratorService, + private readonly effUsername: GeneratorService, + private readonly subaddress: GeneratorService, + private readonly addyIo: GeneratorService, + private readonly duckDuckGo: GeneratorService, + private readonly fastmail: GeneratorService, + private readonly firefoxRelay: GeneratorService, + private readonly forwardEmail: GeneratorService, + private readonly simpleLogin: GeneratorService, + ) {} + + generateUsername(options: UsernameGeneratorOptions) { + if (options.type === "catchall") { + return this.generateCatchall(options); + } else if (options.type === "subaddress") { + return this.generateSubaddress(options); + } else if (options.type === "forwarded") { + return this.generateForwarded(options); + } else { + return this.generateWord(options); + } + } + + generateWord(options: UsernameGeneratorOptions) { + return this.effUsername.generate(options); + } + + generateSubaddress(options: UsernameGeneratorOptions) { + return this.subaddress.generate(options); + } + + generateCatchall(options: UsernameGeneratorOptions) { + return this.catchall.generate(options); + } + + generateForwarded(options: UsernameGeneratorOptions) { + if (!options.forwardedService) { + return null; + } + + const stored = this.toStoredOptions(options); + switch (options.forwardedService) { + case Forwarders.AddyIo.id: + return this.addyIo.generate(stored.forwarders.addyIo); + case Forwarders.DuckDuckGo.id: + return this.duckDuckGo.generate(stored.forwarders.duckDuckGo); + case Forwarders.Fastmail.id: + return this.fastmail.generate(stored.forwarders.fastmail); + case Forwarders.FirefoxRelay.id: + return this.firefoxRelay.generate(stored.forwarders.firefoxRelay); + case Forwarders.ForwardEmail.id: + return this.forwardEmail.generate(stored.forwarders.forwardEmail); + case Forwarders.SimpleLogin.id: + return this.simpleLogin.generate(stored.forwarders.simpleLogin); + } + } + + getOptions$() { + // look upon my works, ye mighty, and despair! + const options$ = this.accountService.activeAccount$.pipe( + concatMap((account) => + combineLatest([ + this.navigation.options$(account.id), + this.navigation.defaults$(account.id), + this.catchall.options$(account.id), + this.catchall.defaults$(account.id), + this.effUsername.options$(account.id), + this.effUsername.defaults$(account.id), + this.subaddress.options$(account.id), + this.subaddress.defaults$(account.id), + this.addyIo.options$(account.id), + this.addyIo.defaults$(account.id), + this.duckDuckGo.options$(account.id), + this.duckDuckGo.defaults$(account.id), + this.fastmail.options$(account.id), + this.fastmail.defaults$(account.id), + this.firefoxRelay.options$(account.id), + this.firefoxRelay.defaults$(account.id), + this.forwardEmail.options$(account.id), + this.forwardEmail.defaults$(account.id), + this.simpleLogin.options$(account.id), + this.simpleLogin.defaults$(account.id), + ]), + ), + map( + ([ + generatorOptions, + generatorDefaults, + catchallOptions, + catchallDefaults, + effUsernameOptions, + effUsernameDefaults, + subaddressOptions, + subaddressDefaults, + addyIoOptions, + addyIoDefaults, + duckDuckGoOptions, + duckDuckGoDefaults, + fastmailOptions, + fastmailDefaults, + firefoxRelayOptions, + firefoxRelayDefaults, + forwardEmailOptions, + forwardEmailDefaults, + simpleLoginOptions, + simpleLoginDefaults, + ]) => + this.toUsernameOptions({ + generator: generatorOptions ?? generatorDefaults, + algorithms: { + catchall: catchallOptions ?? catchallDefaults, + effUsername: effUsernameOptions ?? effUsernameDefaults, + subaddress: subaddressOptions ?? subaddressDefaults, + }, + forwarders: { + addyIo: addyIoOptions ?? addyIoDefaults, + duckDuckGo: duckDuckGoOptions ?? duckDuckGoDefaults, + fastmail: fastmailOptions ?? fastmailDefaults, + firefoxRelay: firefoxRelayOptions ?? firefoxRelayDefaults, + forwardEmail: forwardEmailOptions ?? forwardEmailDefaults, + simpleLogin: simpleLoginOptions ?? simpleLoginDefaults, + }, + }), + ), + ); + + return options$; + } + + getOptions() { + return firstValueFrom(this.getOptions$()); + } + + async saveOptions(options: UsernameGeneratorOptions) { + const stored = this.toStoredOptions(options); + const activeAccount = await firstValueFrom(this.accountService.activeAccount$); + + // generator settings needs to preserve whether password or passphrase is selected, + // so `navigationOptions` is mutated. + const navigationOptions$ = zip( + this.navigation.options$(activeAccount.id), + this.navigation.defaults$(activeAccount.id), + ).pipe(map(([options, defaults]) => options ?? defaults)); + let navigationOptions = await firstValueFrom(navigationOptions$); + navigationOptions = Object.assign(navigationOptions, stored.generator); + await this.navigation.saveOptions(activeAccount.id, navigationOptions); + + // overwrite all other settings with latest values + await Promise.all([ + this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall), + this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername), + this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress), + this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo), + this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo), + this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail), + this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay), + this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail), + this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin), + ]); + } + + private toStoredOptions(options: UsernameGeneratorOptions) { + const forwarders = { + addyIo: { + baseUrl: options.forwardedAnonAddyBaseUrl, + token: options.forwardedAnonAddyApiToken, + domain: options.forwardedAnonAddyDomain, + website: options.website, + }, + duckDuckGo: { + token: options.forwardedDuckDuckGoToken, + website: options.website, + }, + fastmail: { + token: options.forwardedFastmailApiToken, + website: options.website, + }, + firefoxRelay: { + token: options.forwardedFirefoxApiToken, + website: options.website, + }, + forwardEmail: { + token: options.forwardedForwardEmailApiToken, + domain: options.forwardedForwardEmailDomain, + website: options.website, + }, + simpleLogin: { + token: options.forwardedSimpleLoginApiKey, + baseUrl: options.forwardedSimpleLoginBaseUrl, + website: options.website, + }, + }; + + const generator = { + username: options.type, + forwarder: options.forwardedService, + }; + + const algorithms = { + effUsername: { + wordCapitalize: options.wordCapitalize, + wordIncludeNumber: options.wordIncludeNumber, + website: options.website, + }, + subaddress: { + subaddressType: options.subaddressType, + subaddressEmail: options.subaddressEmail, + website: options.website, + }, + catchall: { + catchallType: options.catchallType, + catchallDomain: options.catchallDomain, + website: options.website, + }, + }; + + return { generator, algorithms, forwarders } as MappedOptions; + } + + private toUsernameOptions(options: MappedOptions) { + return { + type: options.generator.username, + wordCapitalize: options.algorithms.effUsername.wordCapitalize, + wordIncludeNumber: options.algorithms.effUsername.wordIncludeNumber, + subaddressType: options.algorithms.subaddress.subaddressType, + subaddressEmail: options.algorithms.subaddress.subaddressEmail, + catchallType: options.algorithms.catchall.catchallType, + catchallDomain: options.algorithms.catchall.catchallDomain, + forwardedService: options.generator.forwarder, + forwardedAnonAddyApiToken: options.forwarders.addyIo.token, + forwardedAnonAddyDomain: options.forwarders.addyIo.domain, + forwardedAnonAddyBaseUrl: options.forwarders.addyIo.baseUrl, + forwardedDuckDuckGoToken: options.forwarders.duckDuckGo.token, + forwardedFirefoxApiToken: options.forwarders.firefoxRelay.token, + forwardedFastmailApiToken: options.forwarders.fastmail.token, + forwardedForwardEmailApiToken: options.forwarders.forwardEmail.token, + forwardedForwardEmailDomain: options.forwarders.forwardEmail.domain, + forwardedSimpleLoginApiKey: options.forwarders.simpleLogin.token, + forwardedSimpleLoginBaseUrl: options.forwarders.simpleLogin.baseUrl, + } as UsernameGeneratorOptions; + } +} diff --git a/libs/tools/generator/extensions/src/legacy-username/username-generation-options.ts b/libs/tools/generator/extensions/src/legacy-username/username-generation-options.ts new file mode 100644 index 0000000000..9e153066c0 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/username-generation-options.ts @@ -0,0 +1,26 @@ +import { + ForwarderId, + RequestOptions, + CatchallGenerationOptions, + EffUsernameGenerationOptions, + SubaddressGenerationOptions, + UsernameGeneratorType, +} from "@bitwarden/generator-core"; + +export type UsernameGeneratorOptions = EffUsernameGenerationOptions & + SubaddressGenerationOptions & + CatchallGenerationOptions & + RequestOptions & { + type?: UsernameGeneratorType; + forwardedService?: ForwarderId | ""; + forwardedAnonAddyApiToken?: string; + forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; + forwardedDuckDuckGoToken?: string; + forwardedFirefoxApiToken?: string; + forwardedFastmailApiToken?: string; + forwardedForwardEmailApiToken?: string; + forwardedForwardEmailDomain?: string; + forwardedSimpleLoginApiKey?: string; + forwardedSimpleLoginBaseUrl?: string; + }; diff --git a/libs/tools/generator/extensions/src/legacy-username/username-generation.service.abstraction.ts b/libs/tools/generator/extensions/src/legacy-username/username-generation.service.abstraction.ts new file mode 100644 index 0000000000..49428b3c39 --- /dev/null +++ b/libs/tools/generator/extensions/src/legacy-username/username-generation.service.abstraction.ts @@ -0,0 +1,15 @@ +import { Observable } from "rxjs"; + +import { UsernameGeneratorOptions } from "./username-generation-options"; + +/** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */ +export abstract class UsernameGenerationServiceAbstraction { + generateUsername: (options: UsernameGeneratorOptions) => Promise; + generateWord: (options: UsernameGeneratorOptions) => Promise; + generateSubaddress: (options: UsernameGeneratorOptions) => Promise; + generateCatchall: (options: UsernameGeneratorOptions) => Promise; + generateForwarded: (options: UsernameGeneratorOptions) => Promise; + getOptions: () => Promise; + getOptions$: () => Observable; + saveOptions: (options: UsernameGeneratorOptions) => Promise; +} diff --git a/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.spec.ts b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.spec.ts new file mode 100644 index 0000000000..f531783810 --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.spec.ts @@ -0,0 +1,98 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec"; + +import { GENERATOR_SETTINGS } from "./key-definitions"; + +import { + GeneratorNavigationEvaluator, + DefaultGeneratorNavigationService, + DefaultGeneratorNavigation, +} from "./"; + +const SomeUser = "some user" as UserId; + +describe("DefaultGeneratorNavigationService", () => { + describe("options$", () => { + it("emits options", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const settings = { type: "password" as const }; + await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser); + const navigation = new DefaultGeneratorNavigationService(stateProvider, null); + + const result = await firstValueFrom(navigation.options$(SomeUser)); + + expect(result).toEqual(settings); + }); + }); + + describe("defaults$", () => { + it("emits default options", async () => { + const navigation = new DefaultGeneratorNavigationService(null, null); + + const result = await firstValueFrom(navigation.defaults$(SomeUser)); + + expect(result).toEqual(DefaultGeneratorNavigation); + }); + }); + + describe("evaluator$", () => { + it("emits a GeneratorNavigationEvaluator", async () => { + const policyService = mock({ + getAll$() { + return of([]); + }, + }); + const navigation = new DefaultGeneratorNavigationService(null, policyService); + + const result = await firstValueFrom(navigation.evaluator$(SomeUser)); + + expect(result).toBeInstanceOf(GeneratorNavigationEvaluator); + }); + }); + + describe("enforcePolicy", () => { + it("applies policy", async () => { + const policyService = mock({ + getAll$(_type: PolicyType, _user: UserId) { + return of([ + new Policy({ + id: "" as any, + organizationId: "" as any, + enabled: true, + type: PolicyType.PasswordGenerator, + data: { defaultType: "password" }, + }), + ]); + }, + }); + const navigation = new DefaultGeneratorNavigationService(null, policyService); + const options = {}; + + const result = await navigation.enforcePolicy(SomeUser, options); + + expect(result).toMatchObject({ type: "password" }); + }); + }); + + describe("saveOptions", () => { + it("updates options$", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const navigation = new DefaultGeneratorNavigationService(stateProvider, null); + const settings = { type: "password" as const }; + + await navigation.saveOptions(SomeUser, settings); + const result = await firstValueFrom(navigation.options$(SomeUser)); + + expect(result).toEqual(settings); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.ts b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.ts new file mode 100644 index 0000000000..10781786cf --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.service.ts @@ -0,0 +1,73 @@ +import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { distinctIfShallowMatch, reduceCollection } from "@bitwarden/common/tools/rx"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultGeneratorNavigation } from "./default-generator-navigation"; +import { GeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; +import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; +import { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; +import { GENERATOR_SETTINGS } from "./key-definitions"; + +export class DefaultGeneratorNavigationService implements GeneratorNavigationService { + /** instantiates the password generator strategy. + * @param stateProvider provides durable state + * @param policy provides the policy to enforce + */ + constructor( + private readonly stateProvider: StateProvider, + private readonly policy: PolicyService, + ) {} + + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + * @param userId: Identifies the user making the request + */ + options$(userId: UserId): Observable { + return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId); + } + + /** Gets the default options. */ + defaults$(userId: UserId): Observable { + return new BehaviorSubject({ ...DefaultGeneratorNavigation }); + } + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + * @param userId: Identifies the user making the request + */ + evaluator$(userId: UserId) { + const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe( + reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy), + distinctIfShallowMatch(), + map((policy) => new GeneratorNavigationEvaluator(policy)), + ); + + return evaluator$; + } + + /** Enforces the policy on the given options + * @param userId: Identifies the user making the request + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + async enforcePolicy(userId: UserId, options: GeneratorNavigation) { + const evaluator = await firstValueFrom(this.evaluator$(userId)); + const applied = evaluator.applyPolicy(options); + const sanitized = evaluator.sanitize(applied); + return sanitized; + } + + /** Saves the navigation options to disk. + * @param userId: Identifies the user making the request + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + async saveOptions(userId: UserId, options: GeneratorNavigation): Promise { + await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId); + } +} diff --git a/libs/tools/generator/extensions/src/navigation/default-generator-navigation.ts b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.ts new file mode 100644 index 0000000000..18efc3b104 --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/default-generator-navigation.ts @@ -0,0 +1,8 @@ +import { GeneratorNavigation } from "./generator-navigation"; + +/** The default options for password generation. */ +export const DefaultGeneratorNavigation: Partial = Object.freeze({ + type: "password", + username: "word", + forwarder: "", +}); diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.spec.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.spec.ts new file mode 100644 index 0000000000..6fa8f2ef8f --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.spec.ts @@ -0,0 +1,64 @@ +import { DefaultGeneratorNavigation } from "./default-generator-navigation"; +import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; + +describe("GeneratorNavigationEvaluator", () => { + describe("policyInEffect", () => { + it.each([["passphrase"], ["password"]] as const)( + "returns true if the policy has a defaultType (= %p)", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + expect(evaluator.policyInEffect).toEqual(true); + }, + ); + + it.each([[undefined], [null], ["" as any]])( + "returns false if the policy has a falsy defaultType (= %p)", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + expect(evaluator.policyInEffect).toEqual(false); + }, + ); + }); + + describe("applyPolicy", () => { + it("returns the input options", () => { + const evaluator = new GeneratorNavigationEvaluator(null); + const options = { type: "password" as const }; + + const result = evaluator.applyPolicy(options); + + expect(result).toEqual(options); + }); + }); + + describe("sanitize", () => { + it.each([["passphrase"], ["password"]] as const)( + "defaults options to the policy's default type (= %p) when a policy is in effect", + (defaultType) => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType }); + + const result = evaluator.sanitize({}); + + expect(result).toEqual({ type: defaultType }); + }, + ); + + it("defaults options to the default generator navigation type when a policy is not in effect", () => { + const evaluator = new GeneratorNavigationEvaluator(null); + + const result = evaluator.sanitize({}); + + expect(result.type).toEqual(DefaultGeneratorNavigation.type); + }); + + it("retains the options type when it is set", () => { + const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" }); + + const result = evaluator.sanitize({ type: "password" }); + + expect(result).toEqual({ type: "password" }); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.ts new file mode 100644 index 0000000000..772342a73a --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation-evaluator.ts @@ -0,0 +1,44 @@ +import { PolicyEvaluator } from "@bitwarden/generator-core"; + +import { DefaultGeneratorNavigation } from "./default-generator-navigation"; +import { GeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationPolicy } from "./generator-navigation-policy"; + +/** Enforces policy for generator navigation options. + */ +export class GeneratorNavigationEvaluator + implements PolicyEvaluator +{ + /** Instantiates the evaluator. + * @param policy The policy applied by the evaluator. When this conflicts with + * the defaults, the policy takes precedence. + */ + constructor(readonly policy: GeneratorNavigationPolicy) {} + + /** {@link PolicyEvaluator.policyInEffect} */ + get policyInEffect(): boolean { + return this.policy?.defaultType ? true : false; + } + + /** Apply policy to the input options. + * @param options The options to build from. These options are not altered. + * @returns A new password generation request with policy applied. + */ + applyPolicy(options: GeneratorNavigation): GeneratorNavigation { + return options; + } + + /** Ensures internal options consistency. + * @param options The options to cascade. These options are not altered. + * @returns A passphrase generation request with cascade applied. + */ + sanitize(options: GeneratorNavigation): GeneratorNavigation { + const defaultType = this.policyInEffect + ? this.policy.defaultType + : DefaultGeneratorNavigation.type; + return { + ...options, + type: options.type ?? defaultType, + }; + } +} diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.spec.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.spec.ts new file mode 100644 index 0000000000..3c90f8a7e8 --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.spec.ts @@ -0,0 +1,63 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { PolicyId } from "@bitwarden/common/types/guid"; + +import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy"; + +function createPolicy( + data: any, + type: PolicyType = PolicyType.PasswordGenerator, + enabled: boolean = true, +) { + return new Policy({ + id: "id" as PolicyId, + organizationId: "organizationId", + data, + enabled, + type, + }); +} + +describe("leastPrivilege", () => { + it("should return the accumulator when the policy type does not apply", () => { + const policy = createPolicy({}, PolicyType.RequireSso); + + const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); + + expect(result).toEqual(DisabledGeneratorNavigationPolicy); + }); + + it("should return the accumulator when the policy is not enabled", () => { + const policy = createPolicy({}, PolicyType.PasswordGenerator, false); + + const result = preferPassword(DisabledGeneratorNavigationPolicy, policy); + + expect(result).toEqual(DisabledGeneratorNavigationPolicy); + }); + + it("should take the %p from the policy", () => { + const policy = createPolicy({ defaultType: "passphrase" }); + + const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy); + + expect(result).toEqual({ defaultType: "passphrase" }); + }); + + it("should override passphrase with password", () => { + const policy = createPolicy({ defaultType: "password" }); + + const result = preferPassword({ defaultType: "passphrase" }, policy); + + expect(result).toEqual({ defaultType: "password" }); + }); + + it("should not override password", () => { + const policy = createPolicy({ defaultType: "passphrase" }); + + const result = preferPassword({ defaultType: "password" }, policy); + + expect(result).toEqual({ defaultType: "password" }); + }); +}); diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.ts new file mode 100644 index 0000000000..f52344d1fd --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation-policy.ts @@ -0,0 +1,39 @@ +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +// FIXME: use index.ts imports once policy abstractions and models +// implement ADR-0002 +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GeneratorType } from "@bitwarden/generator-core"; + +/** Policy settings affecting password generator navigation */ +export type GeneratorNavigationPolicy = { + /** The type of generator that should be shown by default when opening + * the password generator. + */ + defaultType?: GeneratorType; +}; + +/** Reduces a policy into an accumulator by preferring the password generator + * type to other generator types. + * @param acc the accumulator + * @param policy the policy to reduce + * @returns the resulting `GeneratorNavigationPolicy` + */ +export function preferPassword( + acc: GeneratorNavigationPolicy, + policy: Policy, +): GeneratorNavigationPolicy { + const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled; + if (!isEnabled) { + return acc; + } + + const isOverridable = acc.defaultType !== "password" && policy.data.defaultType; + const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc; + + return result; +} + +/** The default options for password generation policy. */ +export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({ + defaultType: undefined, +}); diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation.service.abstraction.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation.service.abstraction.ts new file mode 100644 index 0000000000..ae6d2a03f3 --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation.service.abstraction.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; +import { PolicyEvaluator } from "@bitwarden/generator-core"; + +import { GeneratorNavigation } from "./generator-navigation"; +import { GeneratorNavigationPolicy } from "./generator-navigation-policy"; + +/** Loads and stores generator navigational data + */ +export abstract class GeneratorNavigationService { + /** An observable monitoring the options saved to disk. + * The observable updates when the options are saved. + * @param userId: Identifies the user making the request + */ + options$: (userId: UserId) => Observable; + + /** Gets the default options. */ + defaults$: (userId: UserId) => Observable; + + /** An observable monitoring the options used to enforce policy. + * The observable updates when the policy changes. + * @param userId: Identifies the user making the request + */ + evaluator$: ( + userId: UserId, + ) => Observable>; + + /** Enforces the policy on the given options + * @param userId: Identifies the user making the request + * @param options the options to enforce the policy on + * @returns a new instance of the options with the policy enforced + */ + enforcePolicy: (userId: UserId, options: GeneratorNavigation) => Promise; + + /** Saves the navigation options to disk. + * @param userId: Identifies the user making the request + * @param options the options to save + * @returns a promise that resolves when the options are saved + */ + saveOptions: (userId: UserId, options: GeneratorNavigation) => Promise; +} diff --git a/libs/tools/generator/extensions/src/navigation/generator-navigation.ts b/libs/tools/generator/extensions/src/navigation/generator-navigation.ts new file mode 100644 index 0000000000..5a35e57d7b --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/generator-navigation.ts @@ -0,0 +1,16 @@ +import { GeneratorType, ForwarderId, UsernameGeneratorType } from "@bitwarden/generator-core"; + +/** Stores credential generator UI state. */ +export type GeneratorNavigation = { + /** The kind of credential being generated. + * @remarks The legacy generator only supports "password" and "passphrase". + * The componentized generator supports all values. + */ + type?: GeneratorType; + + /** When `type === "username"`, this stores the username algorithm. */ + username?: UsernameGeneratorType; + + /** When `username === "forwarded"`, this stores the forwarder implementation. */ + forwarder?: ForwarderId | ""; +}; diff --git a/libs/tools/generator/extensions/src/navigation/index.ts b/libs/tools/generator/extensions/src/navigation/index.ts new file mode 100644 index 0000000000..ab5cc55b16 --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/index.ts @@ -0,0 +1,6 @@ +export { DefaultGeneratorNavigation } from "./default-generator-navigation"; +export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service"; +export { GeneratorNavigation } from "./generator-navigation"; +export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator"; +export { GeneratorNavigationService } from "./generator-navigation.service.abstraction"; +export { GeneratorNavigationPolicy } from "./generator-navigation-policy"; diff --git a/libs/tools/generator/extensions/src/navigation/key-definition.spec.ts b/libs/tools/generator/extensions/src/navigation/key-definition.spec.ts new file mode 100644 index 0000000000..81f74ea28e --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/key-definition.spec.ts @@ -0,0 +1,11 @@ +import { GENERATOR_SETTINGS } from "./key-definitions"; + +describe("Key definitions", () => { + describe("GENERATOR_SETTINGS", () => { + it("should pass through deserialization", () => { + const value = {}; + const result = GENERATOR_SETTINGS.deserializer(value); + expect(result).toBe(value); + }); + }); +}); diff --git a/libs/tools/generator/extensions/src/navigation/key-definitions.ts b/libs/tools/generator/extensions/src/navigation/key-definitions.ts new file mode 100644 index 0000000000..36a190ce5e --- /dev/null +++ b/libs/tools/generator/extensions/src/navigation/key-definitions.ts @@ -0,0 +1,13 @@ +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { GeneratorNavigation } from "./generator-navigation"; + +/** plaintext password generation options */ +export const GENERATOR_SETTINGS = new UserKeyDefinition( + GENERATOR_DISK, + "generatorSettings", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +);