diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index b29f0dcad76..f651af9dd7d 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -185,6 +185,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Set up environment run: | sudo apt-get update @@ -334,6 +341,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Set up environment run: | sudo apt-get update @@ -475,6 +489,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -734,6 +755,13 @@ jobs: cache-dependency-path: '**/package-lock.json' node-version: ${{ env._NODE_VERSION }} + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Install AST run: dotnet tool install --global AzureSignTool --version 4.0.1 @@ -976,6 +1004,13 @@ jobs: - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version @@ -1206,6 +1241,13 @@ jobs: - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version @@ -1471,6 +1513,13 @@ jobs: - name: Set up Node-gyp run: python3 -m pip install setuptools + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 # v2.8.1 + with: + workspaces: | + apps/desktop/desktop_native -> target + cache-targets: "true" + - name: Print environment run: | node --version diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 497da803686..719063958f7 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -315,7 +315,7 @@ jobs: - name: Install Cosign if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' - uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1 + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - name: Sign image with Cosign if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main' diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 46309af38ea..0e0597fccf0 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -15,6 +15,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: + actions: read contents: read id-token: write pull-requests: write diff --git a/.github/workflows/test-browser-interactions.yml b/.github/workflows/test-browser-interactions.yml index 6aca75fa859..bc50a623172 100644 --- a/.github/workflows/test-browser-interactions.yml +++ b/.github/workflows/test-browser-interactions.yml @@ -49,7 +49,9 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + # NOTE: versions of actions/create-github-app-token after 2.0.3 break this workflow + # Remediation is tracked in https://bitwarden.atlassian.net/browse/PM-28174 + uses: actions/create-github-app-token@30bf6253fa41bdc8d1501d202ad15287582246b4 # v2.0.3 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0ff2db480c1..2384cf8c4ec 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -594,6 +594,9 @@ "viewAll": { "message": "View all" }, + "viewLess": { + "message": "View less" + }, "viewLogin": { "message": "View login" }, diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 2babd2a7ef6..37efcee9012 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -86,12 +86,12 @@ - - + {{ "vaultTimeoutAction1" | i18n }} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index c5423a5f1d1..e6e7be96c08 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -24,7 +24,7 @@ import { import { JslibModule } from "@bitwarden/angular/jslib.module"; import { NudgesService, NudgeType } from "@bitwarden/angular/vault"; import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component"; -import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; import { LockService } from "@bitwarden/auth/common"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -69,7 +69,10 @@ import { BiometricStateService, BiometricsStatus, } from "@bitwarden/key-management"; -import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; +import { + SessionTimeoutInputComponent, + SessionTimeoutSettingsComponent, +} from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; import { BrowserApi } from "../../../platform/browser/browser-api"; @@ -106,7 +109,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, - VaultTimeoutInputComponent, + SessionTimeoutInputComponent, ], }) export class AccountSecurityComponent implements OnInit, OnDestroy { diff --git a/apps/browser/src/autofill/background/abstractions/overlay.background.ts b/apps/browser/src/autofill/background/abstractions/overlay.background.ts index 18cf1d20446..96809fa26b2 100644 --- a/apps/browser/src/autofill/background/abstractions/overlay.background.ts +++ b/apps/browser/src/autofill/background/abstractions/overlay.background.ts @@ -47,6 +47,7 @@ export type FocusedFieldData = { accountCreationFieldType?: string; showPasskeys?: boolean; focusedFieldForm?: string; + focusedFieldOpid?: string; }; export type InlineMenuElementPosition = { diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 35585d58863..f3278fa6b07 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1176,6 +1176,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { fillNewPassword: true, allowTotpAutofill: true, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, + focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, }); if (totpCode) { @@ -1861,6 +1862,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { fillNewPassword: true, allowTotpAutofill: false, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, + focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, }); globalThis.setTimeout(async () => { diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 13a00fb573f..85bf8c16610 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -29,6 +29,7 @@ export interface AutoFillOptions { allowTotpAutofill?: boolean; autoSubmitLogin?: boolean; focusedFieldForm?: string; + focusedFieldOpid?: string; } export interface FormData { @@ -47,6 +48,7 @@ export interface GenerateFillScriptOptions { cipher: CipherView; tabUrl: string; defaultUriMatch: UriMatchStrategySetting; + focusedFieldOpid?: string; } export type CollectPageDetailsResponseMessage = { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 7c98859070a..7854dc8e161 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -975,6 +975,7 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ showPasskeys: !!autofillFieldData?.showPasskeys, accountCreationFieldType: autofillFieldData?.accountCreationFieldType, focusedFieldForm: autofillFieldData?.form, + focusedFieldOpid: autofillFieldData?.opid, }; const allFields = this.formFieldElements; @@ -1085,7 +1086,15 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ pageDetails, ) ) { - this.setQualifiedAccountCreationFillType(autofillFieldData); + const hasUsernameField = [...this.formFieldElements.values()].some((field) => + this.inlineMenuFieldQualificationService.isUsernameField(field), + ); + + if (hasUsernameField) { + void this.setQualifiedLoginFillType(autofillFieldData); + } else { + this.setQualifiedAccountCreationFillType(autofillFieldData); + } return false; } diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index bfeaa360a39..b436214f327 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -50,6 +50,7 @@ import AutofillPageDetails from "../models/autofill-page-details"; import AutofillScript from "../models/autofill-script"; import { createAutofillFieldMock, + createAutofillFormMock, createAutofillPageDetailsMock, createAutofillScriptMock, createChromeTabMock, @@ -2309,6 +2310,147 @@ describe("AutofillService", () => { untrustedIframe: false, }); }); + + describe("given a focused username field", () => { + let focusedField: AutofillField; + let passwordField: AutofillField; + + beforeEach(() => { + focusedField = createAutofillFieldMock({ + opid: "focused-username", + type: "text", + form: "form1", + elementNumber: 1, + }); + passwordField = createAutofillFieldMock({ + opid: "password", + type: "password", + form: "form1", + elementNumber: 2, + }); + pageDetails.forms = { + form1: createAutofillFormMock({ opid: "form1" }), + }; + options.focusedFieldOpid = "focused-username"; + jest.spyOn(autofillService as any, "inUntrustedIframe").mockResolvedValue(false); + jest.spyOn(AutofillService, "fillByOpid"); + }); + + it("will return early when no matching password is found and set autosubmit if enabled", async () => { + pageDetails.fields = [focusedField]; + options.autoSubmitLogin = true; + + const value = await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledTimes(1); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedField, + options.cipher.login.username, + ); + expect(value.autosubmit).toEqual(["form1"]); + }); + + it("will prioritize focused field and skip passwords in different forms", async () => { + const otherUsername = createAutofillFieldMock({ + opid: "other-username", + type: "text", + form: "form1", + elementNumber: 2, + }); + const passwordDifferentForm = createAutofillFieldMock({ + opid: "password-different", + type: "password", + form: "form2", + elementNumber: 1, + }); + pageDetails.fields = [focusedField, otherUsername, passwordField, passwordDifferentForm]; + pageDetails.forms.form2 = createAutofillFormMock({ opid: "form2" }); + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedField, + options.cipher.login.username, + ); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + passwordField, + options.cipher.login.password, + ); + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + otherUsername, + expect.anything(), + ); + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + passwordDifferentForm, + expect.anything(), + ); + }); + + it("will not fill focused field if already in filledFields", async () => { + pageDetails.fields = [focusedField, passwordField]; + filledFields[focusedField.opid] = focusedField; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).not.toHaveBeenCalledWith( + fillScript, + focusedField, + expect.anything(), + ); + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + passwordField, + options.cipher.login.password, + ); + }); + + it.each([ + ["email", "email"], + ["tel", "tel"], + ])("will treat focused %s field as username field", async (type, opid) => { + const focusedTypedField = createAutofillFieldMock({ + opid: `focused-${opid}`, + type: type as "email" | "tel", + form: "form1", + elementNumber: 1, + }); + pageDetails.fields = [focusedTypedField, passwordField]; + options.focusedFieldOpid = `focused-${opid}`; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(AutofillService.fillByOpid).toHaveBeenCalledWith( + fillScript, + focusedTypedField, + options.cipher.login.username, + ); + }); + }); }); }); diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index f5df17083ce..fcc8861228b 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -451,6 +451,7 @@ export default class AutofillService implements AutofillServiceInterface { cipher: options.cipher, tabUrl: tab.url, defaultUriMatch: defaultUriMatch, + focusedFieldOpid: options.focusedFieldOpid, }); if (!fillScript || !fillScript.script || !fillScript.script.length) { @@ -837,7 +838,7 @@ export default class AutofillService implements AutofillServiceInterface { } const passwords: AutofillField[] = []; - const usernames: AutofillField[] = []; + const usernames = new Map(); const totps: AutofillField[] = []; let pf: AutofillField = null; let username: AutofillField = null; @@ -871,6 +872,70 @@ export default class AutofillService implements AutofillServiceInterface { const prioritizedPasswordFields = loginPasswordFields.length > 0 ? loginPasswordFields : registrationPasswordFields; + const focusedField = + options.focusedFieldOpid && + pageDetails.fields.find((f) => f.opid === options.focusedFieldOpid); + const focusedForm = focusedField?.form; + + const isFocusedTotpField = + focusedField && + options.allowTotpAutofill && + (focusedField.type === "text" || + focusedField.type === "number" || + focusedField.type === "tel") && + (AutofillService.fieldIsFuzzyMatch(focusedField, [ + ...AutoFillConstants.TotpFieldNames, + ...AutoFillConstants.AmbiguousTotpFieldNames, + ]) || + focusedField.autoCompleteType === "one-time-code") && + !AutofillService.fieldIsFuzzyMatch(focusedField, [ + ...AutoFillConstants.RecoveryCodeFieldNames, + ]); + + const focusedUsernameField = + focusedField && + !isFocusedTotpField && + login.username && + (focusedField.type === "text" || + focusedField.type === "email" || + focusedField.type === "tel") && + focusedField; + + const passwordMatchesFocused = (pf: AutofillField): boolean => + !focusedField + ? true + : focusedForm != null + ? pf.form === focusedForm + : focusedUsernameField && + pf.form == null && + this.findUsernameField(pageDetails, pf, false, false, true)?.opid === + focusedUsernameField.opid; + + const getUsernameForPassword = ( + pf: AutofillField, + withoutForm: boolean, + ): AutofillField | null => { + // use focused username if it matches this password, otherwise fall back to finding username field before password + if (focusedUsernameField && passwordMatchesFocused(pf)) { + return focusedUsernameField; + } + return this.findUsernameField(pageDetails, pf, false, false, withoutForm); + }; + + if (focusedUsernameField && !prioritizedPasswordFields.some(passwordMatchesFocused)) { + if (!Object.prototype.hasOwnProperty.call(filledFields, focusedUsernameField.opid)) { + filledFields[focusedUsernameField.opid] = focusedUsernameField; + AutofillService.fillByOpid(fillScript, focusedUsernameField, login.username); + if (options.autoSubmitLogin && focusedUsernameField.form) { + fillScript.autosubmit = [focusedUsernameField.form]; + } + return AutofillService.setFillScriptForFocus( + { [focusedUsernameField.opid]: focusedUsernameField }, + fillScript, + ); + } + } + for (const formKey in pageDetails.forms) { // eslint-disable-next-line if (!pageDetails.forms.hasOwnProperty(formKey)) { @@ -878,20 +943,25 @@ export default class AutofillService implements AutofillServiceInterface { } prioritizedPasswordFields.forEach((passField) => { + if (focusedField && !passwordMatchesFocused(passField)) { + return; + } + pf = passField; passwords.push(pf); if (login.username) { - username = this.findUsernameField(pageDetails, pf, false, false, false); - + username = getUsernameForPassword(pf, false); if (username) { - usernames.push(username); + usernames.set(username.opid, username); } } if (options.allowTotpAutofill && login.totp) { - totp = this.findTotpField(pageDetails, pf, false, false, false); - + totp = + isFocusedTotpField && passwordMatchesFocused(passField) + ? focusedField + : this.findTotpField(pageDetails, pf, false, false, false); if (totp) { totps.push(totp); } @@ -900,24 +970,30 @@ export default class AutofillService implements AutofillServiceInterface { } if (passwordFields.length && !passwords.length) { - // The page does not have any forms with password fields. Use the first password field on the page and the - // input field just before it as the username. - pf = prioritizedPasswordFields[0]; - passwords.push(pf); + // in the event that password fields exist but weren't processed within form elements. + // select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password. + const passwordFieldToUse = focusedField + ? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0] + : prioritizedPasswordFields[0]; - if (login.username && pf.elementNumber > 0) { - username = this.findUsernameField(pageDetails, pf, false, false, true); + if (passwordFieldToUse) { + passwords.push(passwordFieldToUse); - if (username) { - usernames.push(username); + if (login.username && passwordFieldToUse.elementNumber > 0) { + username = getUsernameForPassword(passwordFieldToUse, true); + if (username) { + usernames.set(username.opid, username); + } } - } - if (options.allowTotpAutofill && login.totp && pf.elementNumber > 0) { - totp = this.findTotpField(pageDetails, pf, false, false, true); - - if (totp) { - totps.push(totp); + if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) { + totp = + isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse) + ? focusedField + : this.findTotpField(pageDetails, passwordFieldToUse, false, false, true); + if (totp) { + totps.push(totp); + } } } } @@ -951,7 +1027,7 @@ export default class AutofillService implements AutofillServiceInterface { totps.push(field); return; case isFillableUsernameField: - usernames.push(field); + usernames.set(field.opid, field); return; default: return; @@ -960,9 +1036,10 @@ export default class AutofillService implements AutofillServiceInterface { } const formElementsSet = new Set(); - usernames.forEach((u) => { - // eslint-disable-next-line - if (filledFields.hasOwnProperty(u.opid)) { + const usernamesToFill = focusedUsernameField ? [focusedUsernameField] : [...usernames.values()]; + + usernamesToFill.forEach((u) => { + if (Object.prototype.hasOwnProperty.call(filledFields, u.opid)) { return; } @@ -2330,12 +2407,14 @@ export default class AutofillService implements AutofillServiceInterface { const includesUsernameFieldName = this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; - const isInSameForm = f.form === passwordField.form; + // only consider fields in same form if both have non-null form values + // null forms are treated as separate + const isInSameForm = + f.form != null && passwordField.form != null && f.form === passwordField.form; // An email or tel field in the same form as the password field is likely a qualified // candidate for autofill, even if visibility checks are unreliable - const isQualifiedUsernameField = - f.form === passwordField.form && (f.type === "email" || f.type === "tel"); + const isQualifiedUsernameField = isInSameForm && (f.type === "email" || f.type === "tel"); if ( !f.disabled && diff --git a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html index 39ec6bc28a6..6f61c5fa446 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/autofill-confirmation-dialog/autofill-confirmation-dialog.component.html @@ -1,60 +1,60 @@ - - {{ "confirmAutofill" | i18n }} +

{{ "confirmAutofillDesc" | i18n }}

- @if (savedUrls.length === 1) { + @if (savedUrls().length === 1) {

{{ "savedWebsite" | i18n }}

-
- {{ savedUrls[0] }} +
+ {{ savedUrls()[0] }}
} - @if (savedUrls.length > 1) { + @if (savedUrls().length > 1) {

- {{ "savedWebsites" | i18n: savedUrls.length }} + {{ "savedWebsites" | i18n: savedUrls().length }}

-
-
- -
- {{ url }} -
-
-
+
+ @for (url of savedUrls(); track url) { +
+ +
+ {{ url }} +
+
+
+ }
}

{{ "currentWebsite" | i18n }}

-
- {{ currentUrl }} +
+ {{ currentUrl() }}
- @if (!viewOnly) { + @if (!viewOnly()) { } - @if (!(showAutofillConfirmation$ | async)) { + @if (!(autofillConfirmationFlagEnabled$ | async)) { } diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts index 5927da6c3d2..7b71c2b470f 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.spec.ts @@ -2,6 +2,7 @@ import { CUSTOM_ELEMENTS_SCHEMA } from "@angular/core"; import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; import { BehaviorSubject, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; @@ -66,11 +67,6 @@ describe("ItemMoreOptionsComponent", () => { resolvedDefaultUriMatchStrategy$: uriMatchStrategy$.asObservable(), }; - const hasSearchText$ = new BehaviorSubject(false); - const vaultPopupItemsService = { - hasSearchText$: hasSearchText$.asObservable(), - }; - const baseCipher = { id: "cipher-1", login: { @@ -120,7 +116,7 @@ describe("ItemMoreOptionsComponent", () => { }, { provide: VaultPopupItemsService, - useValue: vaultPopupItemsService, + useValue: mock({}), }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA], @@ -153,7 +149,7 @@ describe("ItemMoreOptionsComponent", () => { expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); }); - it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => { + it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" }); await component.doAutofill(); @@ -182,7 +178,7 @@ describe("ItemMoreOptionsComponent", () => { }); it("does not show the exact match dialog when the default match strategy is Exact and autofill confirmation is not to be shown", async () => { - // autofill confirmation dialog is not shown when either the feature flag is disabled or search text is not present + // autofill confirmation dialog is not shown when either the feature flag is disabled uriMatchStrategy$.next(UriMatchStrategy.Exact); autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); await component.doAutofill(); @@ -192,9 +188,8 @@ describe("ItemMoreOptionsComponent", () => { describe("autofill confirmation dialog", () => { beforeEach(() => { - // autofill confirmation dialog is shown when feature flag is enabled and search text is present + // autofill confirmation dialog is shown when feature flag is enabled featureFlag$.next(true); - hasSearchText$.next(true); uriMatchStrategy$.next(UriMatchStrategy.Domain); passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true); }); @@ -208,7 +203,7 @@ describe("ItemMoreOptionsComponent", () => { expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher); }); - it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => { + it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled", async () => { autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" }); const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled); @@ -216,8 +211,8 @@ describe("ItemMoreOptionsComponent", () => { expect(openSpy).toHaveBeenCalledTimes(1); const args = openSpy.mock.calls[0][1]; - expect(args.data.currentUrl).toBe("https://page.example.com/path"); - expect(args.data.savedUrls).toEqual([ + expect(args.data?.currentUrl).toBe("https://page.example.com/path"); + expect(args.data?.savedUrls).toEqual([ "https://one.example.com", "https://two.example.com/a", ]); diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index 1316a0d32b8..b498e7cd9a5 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -84,10 +84,9 @@ export class ItemMoreOptionsComponent { protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$; - protected showAutofillConfirmation$ = combineLatest([ - this.configService.getFeatureFlag$(FeatureFlag.AutofillConfirmation), - this.vaultPopupItemsService.hasSearchText$, - ]).pipe(map(([isFeatureFlagEnabled, hasSearchText]) => isFeatureFlagEnabled && hasSearchText)); + protected autofillConfirmationFlagEnabled$ = this.configService + .getFeatureFlag$(FeatureFlag.AutofillConfirmation) + .pipe(map((isFeatureFlagEnabled) => isFeatureFlagEnabled)); protected uriMatchStrategy$ = this.domainSettingsService.resolvedDefaultUriMatchStrategy$; @@ -210,7 +209,7 @@ export class ItemMoreOptionsComponent { const cipherHasAllExactMatchLoginUris = uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact); - const showAutofillConfirmation = await firstValueFrom(this.showAutofillConfirmation$); + const showAutofillConfirmation = await firstValueFrom(this.autofillConfirmationFlagEnabled$); const uriMatchStrategy = await firstValueFrom(this.uriMatchStrategy$); if ( diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts index e6afc69b56a..2e822d82855 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.spec.ts @@ -10,6 +10,7 @@ import { CollectionService } from "@bitwarden/admin-console/common"; 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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; @@ -76,6 +77,10 @@ describe("VaultHeaderV2Component", () => { { provide: MessageSender, useValue: mock() }, { provide: AccountService, useValue: mock() }, { provide: LogService, useValue: mock() }, + { + provide: ConfigService, + useValue: { getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)) }, + }, { provide: VaultPopupItemsService, useValue: mock({ searchText$: new BehaviorSubject("") }), diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html index 68e5baac5f3..224eaccd93c 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.html @@ -4,6 +4,5 @@ [(ngModel)]="searchText" (ngModelChange)="onSearchTextChanged()" appAutofocus - [disabled]="loading$ | async" > diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts new file mode 100644 index 00000000000..37c4804e600 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.spec.ts @@ -0,0 +1,160 @@ +import { CommonModule } from "@angular/common"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; +import { FormsModule } from "@angular/forms"; +import { BehaviorSubject } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; +import { SearchModule } from "@bitwarden/components"; + +import { VaultPopupItemsService } from "../../../services/vault-popup-items.service"; +import { VaultPopupLoadingService } from "../../../services/vault-popup-loading.service"; + +import { VaultV2SearchComponent } from "./vault-v2-search.component"; + +describe("VaultV2SearchComponent", () => { + let component: VaultV2SearchComponent; + let fixture: ComponentFixture; + + const searchText$ = new BehaviorSubject(""); + const loading$ = new BehaviorSubject(false); + const featureFlag$ = new BehaviorSubject(true); + const applyFilter = jest.fn(); + + const createComponent = () => { + fixture = TestBed.createComponent(VaultV2SearchComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + applyFilter.mockClear(); + featureFlag$.next(true); + + await TestBed.configureTestingModule({ + imports: [VaultV2SearchComponent, CommonModule, SearchModule, JslibModule, FormsModule], + providers: [ + { + provide: VaultPopupItemsService, + useValue: { + searchText$, + applyFilter, + }, + }, + { + provide: VaultPopupLoadingService, + useValue: { + loading$, + }, + }, + { + provide: ConfigService, + useValue: { + getFeatureFlag$: jest.fn(() => featureFlag$), + }, + }, + { provide: I18nService, useValue: { t: (key: string) => key } }, + ], + }).compileComponents(); + }); + + it("subscribes to search text from service", () => { + createComponent(); + + searchText$.next("test search"); + fixture.detectChanges(); + + expect(component.searchText).toBe("test search"); + }); + + describe("debouncing behavior", () => { + describe("when feature flag is enabled", () => { + beforeEach(() => { + featureFlag$.next(true); + createComponent(); + }); + + it("debounces search text changes when not loading", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("should not debounce search text changes when loading", fakeAsync(() => { + loading$.next(true); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(0); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("cancels previous debounce when new text is entered", fakeAsync(() => { + loading$.next(false); + + component.searchText = "test"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + component.searchText = "test2"; + component.onSearchTextChanged(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval / 2); + + expect(applyFilter).toHaveBeenCalledWith("test2"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + }); + + describe("when feature flag is disabled", () => { + beforeEach(() => { + featureFlag$.next(false); + createComponent(); + }); + + it("debounces search text changes", fakeAsync(() => { + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + + it("ignores loading state and always debounces", fakeAsync(() => { + loading$.next(true); + + component.searchText = "test"; + component.onSearchTextChanged(); + + expect(applyFilter).not.toHaveBeenCalled(); + + tick(SearchTextDebounceInterval); + + expect(applyFilter).toHaveBeenCalledWith("test"); + expect(applyFilter).toHaveBeenCalledTimes(1); + })); + }); + }); +}); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts index afe71404717..154cd49c5a3 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-search/vault-v2-search.component.ts @@ -2,9 +2,22 @@ import { CommonModule } from "@angular/common"; import { Component, NgZone } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormsModule } from "@angular/forms"; -import { Subject, Subscription, debounceTime, distinctUntilChanged, filter } from "rxjs"; +import { + Subject, + Subscription, + combineLatest, + debounce, + debounceTime, + distinctUntilChanged, + filter, + map, + switchMap, + timer, +} from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SearchTextDebounceInterval } from "@bitwarden/common/vault/services/search.service"; import { SearchModule } from "@bitwarden/components"; @@ -27,6 +40,7 @@ export class VaultV2SearchComponent { constructor( private vaultPopupItemsService: VaultPopupItemsService, private vaultPopupLoadingService: VaultPopupLoadingService, + private configService: ConfigService, private ngZone: NgZone, ) { this.subscribeToLatestSearchText(); @@ -48,13 +62,38 @@ export class VaultV2SearchComponent { }); } - subscribeToApplyFilter(): Subscription { - return this.searchText$ - .pipe(debounceTime(SearchTextDebounceInterval), distinctUntilChanged(), takeUntilDestroyed()) - .subscribe((data) => { + subscribeToApplyFilter(): void { + this.configService + .getFeatureFlag$(FeatureFlag.VaultLoadingSkeletons) + .pipe( + switchMap((enabled) => { + if (!enabled) { + return this.searchText$.pipe( + debounceTime(SearchTextDebounceInterval), + distinctUntilChanged(), + ); + } + + return combineLatest([this.searchText$, this.loading$]).pipe( + debounce(([_, isLoading]) => { + // If loading apply immediately to avoid stale searches. + // After loading completes, debounce to avoid excessive searches. + const delayTime = isLoading ? 0 : SearchTextDebounceInterval; + return timer(delayTime); + }), + distinctUntilChanged( + ([prevText, prevLoading], [newText, newLoading]) => + prevText === newText && prevLoading === newLoading, + ), + map(([text, _]) => text), + ); + }), + takeUntilDestroyed(), + ) + .subscribe((text) => { this.ngZone.runOutsideAngular(() => { this.ngZone.run(() => { - this.vaultPopupItemsService.applyFilter(data); + this.vaultPopupItemsService.applyFilter(text); }); }); }); diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 98018a3d056..4da82144305 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -3660,9 +3660,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uds_windows" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index dffa8d72594..ccf7c1f3796 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -72,7 +72,7 @@ tracing-subscriber = { version = "=0.3.20", features = [ "env-filter", "tracing-log", ] } -typenum = "=1.18.0" +typenum = "=1.19.0" uniffi = "=0.28.3" widestring = "=1.2.0" windows = "=0.61.1" diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 79e21480a76..bf3c46a311f 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -44,12 +44,12 @@

{{ "vaultTimeoutHeader" | i18n }}

- - + {{ diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index ebab653fc85..c0798f1bdf0 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -9,7 +9,6 @@ import { concatMap, map, pairwise, startWith, switchMap, takeUntil, timeout } fr import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { VaultTimeoutInputComponent } from "@bitwarden/auth/angular"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; @@ -55,7 +54,10 @@ import { TypographyModule, } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; -import { SessionTimeoutSettingsComponent } from "@bitwarden/key-management-ui"; +import { + SessionTimeoutInputComponent, + SessionTimeoutSettingsComponent, +} from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; import { SetPinComponent } from "../../auth/components/set-pin.component"; @@ -95,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SectionHeaderComponent, SelectModule, TypographyModule, - VaultTimeoutInputComponent, + SessionTimeoutInputComponent, SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, diff --git a/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts b/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts deleted file mode 100644 index 8579c4c1dc8..00000000000 --- a/apps/web/src/app/auth/core/services/rotateable-key-set.service.spec.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { mock, MockProxy } from "jest-mock-extended"; - -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { KeyService } from "@bitwarden/key-management"; - -import { RotateableKeySetService } from "./rotateable-key-set.service"; - -describe("RotateableKeySetService", () => { - let testBed!: TestBed; - let keyService!: MockProxy; - let encryptService!: MockProxy; - let service!: RotateableKeySetService; - - beforeEach(() => { - keyService = mock(); - encryptService = mock(); - testBed = TestBed.configureTestingModule({ - providers: [ - { provide: KeyService, useValue: keyService }, - { provide: EncryptService, useValue: encryptService }, - ], - }); - service = testBed.inject(RotateableKeySetService); - }); - - describe("createKeySet", () => { - it("should create a new key set", async () => { - const externalKey = createSymmetricKey(); - const userKey = createSymmetricKey(); - const encryptedUserKey = Symbol(); - const encryptedPublicKey = Symbol(); - const encryptedPrivateKey = Symbol(); - keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey as any]); - keyService.getUserKey.mockResolvedValue({ key: userKey.key } as any); - encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey as any); - encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey as any); - - const result = await service.createKeySet(externalKey as any); - - expect(result).toEqual({ - encryptedUserKey, - encryptedPublicKey, - encryptedPrivateKey, - }); - }); - }); -}); - -function createSymmetricKey() { - const key = Utils.fromB64ToArray( - "1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ", - ); - return new SymmetricCryptoKey(key); -} diff --git a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts b/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts deleted file mode 100644 index 0a150b26ae2..00000000000 --- a/apps/web/src/app/auth/core/services/rotateable-key-set.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { inject, Injectable } from "@angular/core"; - -import { RotateableKeySet } from "@bitwarden/auth/common"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { KeyService } from "@bitwarden/key-management"; - -@Injectable({ providedIn: "root" }) -export class RotateableKeySetService { - private readonly keyService = inject(KeyService); - private readonly encryptService = inject(EncryptService); - - /** - * Create a new rotateable key set for the current user, using the provided external key. - * For more information on rotateable key sets, see {@link RotateableKeySet} - * - * @param externalKey The `ExternalKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey} - * @returns RotateableKeySet containing the current users `UserKey` - */ - async createKeySet( - externalKey: ExternalKey, - ): Promise> { - const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(externalKey); - - const userKey = await this.keyService.getUserKey(); - const rawPublicKey = Utils.fromB64ToArray(publicKey); - const encryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( - userKey, - rawPublicKey, - ); - const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey( - rawPublicKey, - userKey, - ); - return new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey); - } - - /** - * Rotates the current user's `UserKey` and updates the provided `RotateableKeySet` with the new keys. - * - * @param keySet The current `RotateableKeySet` for the user - * @returns The updated `RotateableKeySet` with the new `UserKey` - */ - async rotateKeySet( - keySet: RotateableKeySet, - oldUserKey: SymmetricCryptoKey, - newUserKey: SymmetricCryptoKey, - ): Promise> { - // validate parameters - if (!keySet) { - throw new Error("failed to rotate key set: keySet is required"); - } - if (!oldUserKey) { - throw new Error("failed to rotate key set: oldUserKey is required"); - } - if (!newUserKey) { - throw new Error("failed to rotate key set: newUserKey is required"); - } - - const publicKey = await this.encryptService.unwrapEncapsulationKey( - keySet.encryptedPublicKey, - oldUserKey, - ); - if (publicKey == null) { - throw new Error("failed to rotate key set: could not decrypt public key"); - } - const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey( - publicKey, - newUserKey, - ); - const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned( - newUserKey, - publicKey, - ); - - const newRotateableKeySet = new RotateableKeySet( - newEncryptedUserKey, - newEncryptedPublicKey, - keySet.encryptedPrivateKey, - ); - - return newRotateableKeySet; - } -} diff --git a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts index aba5940d752..603e0f2a77d 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/response/webauthn-login-credential.response.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { RotateableKeySet } from "@bitwarden/auth/common"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; import { BaseResponse } from "@bitwarden/common/models/response/base.response"; import { WebauthnLoginCredentialPrfStatus } from "../../../enums/webauthn-login-credential-prf-status.enum"; diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts index 74323773e66..7e263b638e0 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.spec.ts @@ -3,23 +3,26 @@ import { randomBytes } from "crypto"; import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; -import { RotateableKeySet } from "@bitwarden/auth/common"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; +import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { makeEncString, makeSymmetricCryptoKey } from "@bitwarden/common/spec"; import { PrfKey, UserKey } from "@bitwarden/common/types/key"; +import { newGuid } from "@bitwarden/guid"; +import { KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; import { WebauthnLoginCredentialPrfStatus } from "../../enums/webauthn-login-credential-prf-status.enum"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; -import { RotateableKeySetService } from "../rotateable-key-set.service"; import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request"; import { WebauthnLoginCredentialResponse } from "./response/webauthn-login-credential.response"; @@ -32,9 +35,12 @@ describe("WebauthnAdminService", () => { let rotateableKeySetService!: MockProxy; let webAuthnLoginPrfKeyService!: MockProxy; let credentials: MockProxy; + let keyService: MockProxy; let service!: WebauthnLoginAdminService; let originalAuthenticatorAssertionResponse!: AuthenticatorAssertionResponse | any; + const mockUserId = newGuid() as UserId; + const mockUserKey = makeSymmetricCryptoKey(64) as UserKey; beforeAll(() => { // Polyfill missing class @@ -45,12 +51,14 @@ describe("WebauthnAdminService", () => { userVerificationService = mock(); rotateableKeySetService = mock(); webAuthnLoginPrfKeyService = mock(); + keyService = mock(); credentials = mock(); service = new WebauthnLoginAdminService( apiService, userVerificationService, rotateableKeySetService, webAuthnLoginPrfKeyService, + keyService, credentials, ); @@ -58,6 +66,8 @@ describe("WebauthnAdminService", () => { originalAuthenticatorAssertionResponse = global.AuthenticatorAssertionResponse; // Mock the global AuthenticatorAssertionResponse class b/c the class is only available in secure contexts global.AuthenticatorAssertionResponse = MockAuthenticatorAssertionResponse; + + keyService.userKey$.mockReturnValue(of(mockUserKey)); }); beforeEach(() => { @@ -124,7 +134,7 @@ describe("WebauthnAdminService", () => { const request = new EnableCredentialEncryptionRequest(); request.token = assertionOptions.token; request.deviceResponse = assertionOptions.deviceResponse; - request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString; @@ -135,10 +145,10 @@ describe("WebauthnAdminService", () => { const updateCredentialMock = jest.spyOn(apiService, "updateCredential").mockResolvedValue(); // Act - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); // Assert - expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey); + expect(createKeySetMock).toHaveBeenCalledWith(assertionOptions.prfKey, mockUserKey); expect(updateCredentialMock).toHaveBeenCalledWith(request); }); @@ -161,7 +171,7 @@ describe("WebauthnAdminService", () => { // Act try { - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); } catch (error) { // Assert expect(error).toEqual(new Error("invalid credential")); @@ -170,6 +180,19 @@ describe("WebauthnAdminService", () => { } }); + test.each([null, undefined, ""])("should throw an error when userId is %p", async (userId) => { + const response = new MockPublicKeyCredential(); + const assertionOptions: WebAuthnLoginCredentialAssertionView = + new WebAuthnLoginCredentialAssertionView( + "enable_credential_encryption_test_token", + new WebAuthnLoginAssertionResponseRequest(response), + {} as PrfKey, + ); + await expect( + service.enableCredentialEncryption(assertionOptions, userId as any), + ).rejects.toThrow("userId is required"); + }); + it("should throw error when WehAuthnLoginCredentialAssertionView is undefined", async () => { // Arrange const assertionOptions: WebAuthnLoginCredentialAssertionView = undefined; @@ -182,7 +205,7 @@ describe("WebauthnAdminService", () => { // Act try { - await service.enableCredentialEncryption(assertionOptions); + await service.enableCredentialEncryption(assertionOptions, mockUserId); } catch (error) { // Assert expect(error).toEqual(new Error("invalid credential")); diff --git a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts index edcf521efb8..7765d01f75c 100644 --- a/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts +++ b/apps/web/src/app/auth/core/services/webauthn-login/webauthn-login-admin.service.ts @@ -1,24 +1,34 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { Injectable, Optional } from "@angular/core"; -import { BehaviorSubject, filter, from, map, Observable, shareReplay, switchMap, tap } from "rxjs"; +import { + BehaviorSubject, + filter, + firstValueFrom, + from, + map, + Observable, + shareReplay, + switchMap, + tap, +} from "rxjs"; -import { PrfKeySet } from "@bitwarden/auth/common"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; import { WebAuthnLoginCredentialAssertionView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion.view"; import { Verification } from "@bitwarden/common/auth/types/verification"; +import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; +import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { UserId } from "@bitwarden/common/types/guid"; import { UserKey } from "@bitwarden/common/types/key"; -import { UserKeyRotationDataProvider } from "@bitwarden/key-management"; +import { KeyService, UserKeyRotationDataProvider } from "@bitwarden/key-management"; import { CredentialCreateOptionsView } from "../../views/credential-create-options.view"; import { PendingWebauthnLoginCredentialView } from "../../views/pending-webauthn-login-credential.view"; import { WebauthnLoginCredentialView } from "../../views/webauthn-login-credential.view"; -import { RotateableKeySetService } from "../rotateable-key-set.service"; import { EnableCredentialEncryptionRequest } from "./request/enable-credential-encryption.request"; import { SaveCredentialRequest } from "./request/save-credential.request"; @@ -55,6 +65,7 @@ export class WebauthnLoginAdminService private userVerificationService: UserVerificationService, private rotateableKeySetService: RotateableKeySetService, private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction, + private keyService: KeyService, @Optional() navigatorCredentials?: CredentialsContainer, @Optional() private logService?: LogService, ) { @@ -131,10 +142,12 @@ export class WebauthnLoginAdminService * This will trigger the browsers WebAuthn API to generate a PRF-output. * * @param pendingCredential A credential created using `createCredential`. + * @param userId The target users id. * @returns A key set that can be saved to the server. Undefined is returned if the credential doesn't support PRF. */ async createKeySet( pendingCredential: PendingWebauthnLoginCredentialView, + userId: UserId, ): Promise { const nativeOptions: CredentialRequestOptions = { publicKey: { @@ -166,7 +179,8 @@ export class WebauthnLoginAdminService const symmetricPrfKey = await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult); - return await this.rotateableKeySetService.createKeySet(symmetricPrfKey); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + return await this.rotateableKeySetService.createKeySet(symmetricPrfKey, userKey); } catch (error) { this.logService?.error(error); return undefined; @@ -190,7 +204,7 @@ export class WebauthnLoginAdminService request.token = credential.createOptions.token; request.name = name; request.supportsPrf = credential.supportsPrf; - request.encryptedUserKey = prfKeySet?.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet?.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet?.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet?.encryptedPrivateKey.encryptedString; await this.apiService.saveCredential(request); @@ -204,23 +218,31 @@ export class WebauthnLoginAdminService * if there was a problem with the Credential Assertion. * * @param assertionOptions Options received from the server using `getCredentialAssertOptions`. + * @param userId The target users id. * @returns void */ async enableCredentialEncryption( assertionOptions: WebAuthnLoginCredentialAssertionView, + userId: UserId, ): Promise { if (assertionOptions === undefined || assertionOptions?.prfKey === undefined) { throw new Error("invalid credential"); } + if (!userId) { + throw new Error("userId is required"); + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); const prfKeySet: PrfKeySet = await this.rotateableKeySetService.createKeySet( assertionOptions.prfKey, + userKey, ); const request = new EnableCredentialEncryptionRequest(); request.token = assertionOptions.token; request.deviceResponse = assertionOptions.deviceResponse; - request.encryptedUserKey = prfKeySet.encryptedUserKey.encryptedString; + request.encryptedUserKey = prfKeySet.encapsulatedDownstreamKey.encryptedString; request.encryptedPublicKey = prfKeySet.encryptedPublicKey.encryptedString; request.encryptedPrivateKey = prfKeySet.encryptedPrivateKey.encryptedString; await this.apiService.updateCredential(request); @@ -317,7 +339,7 @@ export class WebauthnLoginAdminService const request = new WebauthnRotateCredentialRequest( response.id, rotatedKeyset.encryptedPublicKey, - rotatedKeyset.encryptedUserKey, + rotatedKeyset.encapsulatedDownstreamKey, ); return request; }), diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts index 89b7410baba..8ccf99f1aef 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/create-credential-dialog/create-credential-dialog.component.ts @@ -8,12 +8,13 @@ import { TwoFactorAuthSecurityKeyFailedIcon, TwoFactorAuthSecurityKeyIcon, } from "@bitwarden/assets/svg"; -import { PrfKeySet } from "@bitwarden/auth/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; +import { PrfKeySet } from "@bitwarden/common/key-management/keys/models/rotateable-key-set"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogConfig, DialogRef, DialogService, ToastService } from "@bitwarden/components"; import { WebauthnLoginAdminService } from "../../../core"; @@ -67,10 +68,10 @@ export class CreateCredentialDialogComponent implements OnInit { private formBuilder: FormBuilder, private dialogRef: DialogRef, private webauthnService: WebauthnLoginAdminService, - private platformUtilsService: PlatformUtilsService, private i18nService: I18nService, private logService: LogService, private toastService: ToastService, + private accountService: AccountService, ) {} ngOnInit(): void { @@ -146,13 +147,14 @@ export class CreateCredentialDialogComponent implements OnInit { if (this.formGroup.controls.credentialNaming.controls.name.invalid) { return; } + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); let keySet: PrfKeySet | undefined; if ( this.pendingCredential.supportsPrf && this.formGroup.value.credentialNaming.useForEncryption ) { - keySet = await this.webauthnService.createKeySet(this.pendingCredential); + keySet = await this.webauthnService.createKeySet(this.pendingCredential, userId); if (keySet === undefined) { this.formGroup.controls.credentialNaming.controls.useForEncryption?.setErrors({ diff --git a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts index 24a711cb5b4..053da609345 100644 --- a/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts +++ b/apps/web/src/app/auth/settings/webauthn-login-settings/enable-encryption-dialog/enable-encryption-dialog.component.ts @@ -2,11 +2,13 @@ // @ts-strict-ignore import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, Validators } from "@angular/forms"; -import { Subject } from "rxjs"; +import { firstValueFrom, Subject } from "rxjs"; import { takeUntil } from "rxjs/operators"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction"; import { WebAuthnLoginCredentialAssertionOptionsView } from "@bitwarden/common/auth/models/view/webauthn-login/webauthn-login-credential-assertion-options.view"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Verification } from "@bitwarden/common/auth/types/verification"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { DIALOG_DATA, DialogConfig, DialogRef } from "@bitwarden/components"; @@ -47,6 +49,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { private dialogRef: DialogRef, private webauthnService: WebauthnLoginAdminService, private webauthnLoginService: WebAuthnLoginServiceAbstraction, + private accountService: AccountService, ) {} ngOnInit(): void { @@ -60,6 +63,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { if (this.credential === undefined) { return; } + const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); this.dialogRef.disableClose = true; try { @@ -68,6 +72,7 @@ export class EnableEncryptionDialogComponent implements OnInit, OnDestroy { ); await this.webauthnService.enableCredentialEncryption( await this.webauthnLoginService.assertCredential(this.credentialOptions), + userId, ); } catch (error) { if (error instanceof ErrorResponse && error.statusCode === 400) { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index e20d20b0770..9d17d62e4dc 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -436,7 +436,7 @@ describe("UpgradePaymentService", () => { tier: "families", passwordManager: { additionalStorage: 0, - seats: 6, + seats: 1, sponsored: false, }, }, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index 9bb963c210d..94f1c816168 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -98,41 +98,37 @@ export class UpgradePaymentService { planDetails: PlanDetails, billingAddress: BillingAddress, ): Promise { + const isFamiliesPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; + const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; + + let taxClientCall: Promise | null = null; + + if (isFamiliesPlan) { + // Currently, only Families plan is supported for organization plans + const request: OrganizationSubscriptionPurchase = { + tier: "families", + cadence: "annually", + passwordManager: { seats: 1, additionalStorage: 0, sponsored: false }, + }; + + taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( + request, + billingAddress, + ); + } + + if (isPremiumPlan) { + taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); + } + + if (taxClientCall === null) { + throw new Error("Tax client call is not defined"); + } + try { - const isOrganizationPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Families; - const isPremiumPlan = planDetails.tier === PersonalSubscriptionPricingTierIds.Premium; - - let taxClientCall: Promise | null = null; - - if (isOrganizationPlan) { - const seats = this.getPasswordManagerSeats(planDetails); - if (seats === 0) { - throw new Error("Seats must be greater than 0 for organization plan"); - } - // Currently, only Families plan is supported for organization plans - const request: OrganizationSubscriptionPurchase = { - tier: "families", - cadence: "annually", - passwordManager: { seats, additionalStorage: 0, sponsored: false }, - }; - - taxClientCall = this.taxClient.previewTaxForOrganizationSubscriptionPurchase( - request, - billingAddress, - ); - } - - if (isPremiumPlan) { - taxClientCall = this.taxClient.previewTaxForPremiumSubscriptionPurchase(0, billingAddress); - } - - if (taxClientCall === null) { - throw new Error("Tax client call is not defined"); - } - const preview = await taxClientCall; return preview.tax; - } catch (error: unknown) { + } catch (error) { this.logService.error("Tax calculation failed:", error); throw error; } diff --git a/apps/web/src/app/billing/individual/user-subscription.component.html b/apps/web/src/app/billing/individual/user-subscription.component.html index e801237467a..b7e490cdf2e 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.html +++ b/apps/web/src/app/billing/individual/user-subscription.component.html @@ -37,41 +37,63 @@
{{ sub.expiration | date: "mediumDate" }}
{{ "neverExpires" | i18n }}
-
-
-
-
{{ "status" | i18n }}
-
+
+
+
+
{{ "plan" | i18n }}
+
{{ "premiumMembership" | i18n }}
+
+
+
{{ "status" | i18n }}
+
{{ (subscription && subscriptionStatus) || "-" }} - {{ - "pendingCancellation" | i18n - }} -
-
{{ "nextCharge" | i18n }}
-
- {{ - nextInvoice - ? (sub.subscription.periodEndDate | date: "mediumDate") + - ", " + - (nextInvoice.amount | currency: "$") - : "-" - }} -
-
-
-
- {{ "details" | i18n }} - - - - - {{ i.name }} {{ i.quantity > 1 ? "×" + i.quantity : "" }} @ - {{ i.amount | currency: "$" }} - - {{ i.quantity * i.amount | currency: "$" }} /{{ i.interval | i18n }} - - - + {{ "pendingCancellation" | i18n }} +
+
+
+
{{ "nextChargeHeader" | i18n }}
+
+ + +
+ + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (discountedSubscriptionAmount | currency: "$") + }} + + +
+
+ +
+ + {{ + (sub.subscription.periodEndDate | date: "MMM d, y") + + ", " + + (subscriptionAmount | currency: "$") + }} + +
+
+
+ - +
+
@@ -90,8 +112,27 @@
- -
+
+

{{ "storage" | i18n }}

+

+ {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} +

+ + +
+
+ + +
+
+
+

{{ "additionalOptions" | i18n }}

+

{{ "additionalOptionsDesc" | i18n }}

+
-

{{ "storage" | i18n }}

-

- {{ "subscriptionStorage" | i18n: sub.maxStorageGb || 0 : sub.storageName || "0 MB" }} -

- - -
-
- - -
-
-
- +
diff --git a/apps/web/src/app/billing/individual/user-subscription.component.ts b/apps/web/src/app/billing/individual/user-subscription.component.ts index 19db9ec8e61..c39b5d153b1 100644 --- a/apps/web/src/app/billing/individual/user-subscription.component.ts +++ b/apps/web/src/app/billing/individual/user-subscription.component.ts @@ -7,13 +7,17 @@ import { firstValueFrom, lastValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { BillingCustomerDiscount } from "@bitwarden/common/billing/models/response/organization-subscription.response"; import { SubscriptionResponse } from "@bitwarden/common/billing/models/response/subscription.response"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { DiscountInfo } from "@bitwarden/pricing"; import { AdjustStorageDialogComponent, @@ -42,6 +46,10 @@ export class UserSubscriptionComponent implements OnInit { cancelPromise: Promise; reinstatePromise: Promise; + protected enableDiscountDisplay$ = this.configService.getFeatureFlag$( + FeatureFlag.PM23341_Milestone_2, + ); + constructor( private apiService: ApiService, private platformUtilsService: PlatformUtilsService, @@ -54,6 +62,7 @@ export class UserSubscriptionComponent implements OnInit { private billingAccountProfileStateService: BillingAccountProfileStateService, private toastService: ToastService, private accountService: AccountService, + private configService: ConfigService, ) { this.selfHosted = this.platformUtilsService.isSelfHost(); } @@ -187,6 +196,28 @@ export class UserSubscriptionComponent implements OnInit { return this.sub != null ? this.sub.upcomingInvoice : null; } + get subscriptionAmount(): number { + if (!this.subscription?.items || this.subscription.items.length === 0) { + return 0; + } + + return this.subscription.items.reduce( + (sum, item) => sum + (item.amount || 0) * (item.quantity || 0), + 0, + ); + } + + get discountedSubscriptionAmount(): number { + // Use the upcoming invoice amount from the server as it already includes discounts, + // taxes, prorations, and all other adjustments. Fall back to subscription amount + // if upcoming invoice is not available. + if (this.nextInvoice?.amount != null) { + return this.nextInvoice.amount; + } + + return this.subscriptionAmount; + } + get storagePercentage() { return this.sub != null && this.sub.maxStorageGb ? +(100 * (this.sub.storageGb / this.sub.maxStorageGb)).toFixed(2) @@ -217,4 +248,15 @@ export class UserSubscriptionComponent implements OnInit { return this.subscription.status; } } + + getDiscountInfo(discount: BillingCustomerDiscount | null): DiscountInfo | null { + if (!discount) { + return null; + } + return { + active: discount.active, + percentOff: discount.percentOff, + amountOff: discount.amountOff, + }; + } } diff --git a/apps/web/src/app/billing/shared/billing-shared.module.ts b/apps/web/src/app/billing/shared/billing-shared.module.ts index fb593b39328..12792cd781a 100644 --- a/apps/web/src/app/billing/shared/billing-shared.module.ts +++ b/apps/web/src/app/billing/shared/billing-shared.module.ts @@ -1,6 +1,7 @@ import { NgModule } from "@angular/core"; import { BannerModule } from "@bitwarden/components"; +import { DiscountBadgeComponent } from "@bitwarden/pricing"; import { EnterBillingAddressComponent, EnterPaymentMethodComponent, @@ -28,6 +29,7 @@ import { UpdateLicenseComponent } from "./update-license.component"; BannerModule, EnterPaymentMethodComponent, EnterBillingAddressComponent, + DiscountBadgeComponent, ], declarations: [ BillingHistoryComponent, @@ -51,6 +53,7 @@ import { UpdateLicenseComponent } from "./update-license.component"; OffboardingSurveyComponent, IndividualSelfHostingLicenseUploaderComponent, OrganizationSelfHostingLicenseUploaderComponent, + DiscountBadgeComponent, ], }) export class BillingSharedModule {} diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 4af7e51b800..a2e90dd5889 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -17,12 +17,12 @@ {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - + { this.logService.debug("[RiskInsightsOrchestratorService] Analyzing password health"); this._reportProgressSubject.next(ReportProgress.AnalyzingPasswords); - return this._getCipherHealth(ciphers ?? [], memberCiphers); + return forkJoin({ + memberDetails: of(memberCiphers), + cipherHealthReports: this._getCipherHealth(ciphers ?? [], memberCiphers), + }).pipe( + map(({ memberDetails, cipherHealthReports }) => { + const uniqueMembers = getUniqueMembers(memberDetails); + const totalMemberCount = uniqueMembers.length; + + return { cipherHealthReports, totalMemberCount }; + }), + ); }), - map((cipherHealthReports) => { + map(({ cipherHealthReports, totalMemberCount }) => { this.logService.debug("[RiskInsightsOrchestratorService] Calculating risk scores"); this._reportProgressSubject.next(ReportProgress.CalculatingRisks); - return this.reportService.generateApplicationsReport(cipherHealthReports); + const report = this.reportService.generateApplicationsReport(cipherHealthReports); + return { report, totalMemberCount }; }), tap(() => { this.logService.debug("[RiskInsightsOrchestratorService] Generating report data"); this._reportProgressSubject.next(ReportProgress.GeneratingReport); }), withLatestFrom(this.rawReportData$), - map(([report, previousReport]) => { + map(([{ report, totalMemberCount }, previousReport]) => { // Update the application data const updatedApplicationData = this.reportService.getOrganizationApplications( report, @@ -688,6 +703,7 @@ export class RiskInsightsOrchestratorService { const updatedSummary = this.reportService.getApplicationsSummary( report, updatedApplicationData, + totalMemberCount, ); // For now, merge the report with the critical marking flag to make the enriched type // We don't care about the individual ciphers in this instance @@ -964,6 +980,7 @@ export class RiskInsightsOrchestratorService { const summary = this.reportService.getApplicationsSummary( criticalApplications, enrichedReports.applicationData, + enrichedReports.summaryData.totalMemberCount, ); return { ...enrichedReports, diff --git a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts index 94c9c85f955..37b788a8e3d 100644 --- a/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts +++ b/bitwarden_license/bit-common/src/dirt/reports/risk-insights/services/domain/risk-insights-report.service.ts @@ -83,8 +83,8 @@ export class RiskInsightsReportService { getApplicationsSummary( reports: ApplicationHealthReportDetail[], applicationData: OrganizationReportApplication[], + totalMemberCount: number, ): OrganizationReportSummary { - const totalUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.memberDetails)); const atRiskUniqueMembers = getUniqueMembers(reports.flatMap((x) => x.atRiskMemberDetails)); const criticalReports = this.filterApplicationsByCritical(reports, applicationData); @@ -94,7 +94,7 @@ export class RiskInsightsReportService { ); return { - totalMemberCount: totalUniqueMembers.length, + totalMemberCount: totalMemberCount, totalAtRiskMemberCount: atRiskUniqueMembers.length, totalApplicationCount: reports.length, totalAtRiskApplicationCount: reports.filter((app) => app.atRiskPasswordCount > 0).length, diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html index 4b765a5502e..ab59a36aa6a 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.html @@ -43,7 +43,7 @@
- {{ "newPasswordsAtRisk" | i18n: atRiskPasswordCount() }} + {{ "newPasswordsAtRisk" | i18n: unassignedCipherIds() }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts index 509b3e1314a..30e1db7b438 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/activity-cards/password-change-metric.component.ts @@ -66,16 +66,13 @@ export class PasswordChangeMetricComponent implements OnInit { readonly completedTasksCount = computed( () => this._tasks().filter((task) => task.status === SecurityTaskStatus.Completed).length, ); - readonly uncompletedTasksCount = computed( - () => this._tasks().filter((task) => task.status == SecurityTaskStatus.Pending).length, - ); readonly completedTasksPercent = computed(() => { const total = this.tasksCount(); // Account for case where there are no tasks to avoid NaN return total > 0 ? Math.round((this.completedTasksCount() / total) * 100) : 0; }); - readonly atRiskPasswordCount = computed(() => { + readonly unassignedCipherIds = computed(() => { const atRiskIds = this._atRiskCipherIds(); const tasks = this._tasks(); @@ -83,12 +80,20 @@ export class PasswordChangeMetricComponent implements OnInit { return atRiskIds.length; } - const assignedIdSet = new Set(tasks.map((task) => task.cipherId)); + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); const unassignedIds = atRiskIds.filter((id) => !assignedIdSet.has(id)); return unassignedIds.length; }); + readonly atRiskPasswordCount = computed(() => { + const atRiskIds = this._atRiskCipherIds(); + const atRiskIdsSet = new Set(atRiskIds); + + return atRiskIdsSet.size; + }); + readonly currentView = computed(() => { if (!this._hasCriticalApplications()) { return PasswordChangeView.EMPTY; @@ -96,7 +101,7 @@ export class PasswordChangeMetricComponent implements OnInit { if (this.tasksCount() === 0) { return PasswordChangeView.NO_TASKS_ASSIGNED; } - if (this.atRiskPasswordCount() > 0) { + if (this.unassignedCipherIds() > 0) { return PasswordChangeView.NEW_TASKS_AVAILABLE; } return PasswordChangeView.PROGRESS; diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html index 8e597234f14..09fb5cb7ad9 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.html @@ -38,8 +38,8 @@ @if (currentView() === DialogView.AssignTasks) { diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts index e415fbf9ad0..8655baccda3 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/activity/application-review-dialog/new-applications-dialog.component.ts @@ -2,12 +2,15 @@ import { CommonModule } from "@angular/common"; import { ChangeDetectionStrategy, Component, + computed, DestroyRef, Inject, inject, + Injector, + Signal, signal, } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { from, switchMap, take } from "rxjs"; import { @@ -17,7 +20,8 @@ import { import { getUniqueMembers } from "@bitwarden/bit-common/dirt/reports/risk-insights/helpers"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { CipherId, OrganizationId } from "@bitwarden/common/types/guid"; +import { SecurityTask, SecurityTaskStatus } from "@bitwarden/common/vault/tasks"; import { ButtonModule, DIALOG_DATA, @@ -70,9 +74,9 @@ export type NewApplicationsDialogResultType = (typeof NewApplicationsDialogResultType)[keyof typeof NewApplicationsDialogResultType]; @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, selector: "dirt-new-applications-dialog", templateUrl: "./new-applications-dialog.component.html", - changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, ButtonModule, @@ -95,10 +99,41 @@ export class NewApplicationsDialogComponent { // Applications selected to save as critical applications protected readonly selectedApplications = signal>(new Set()); - // Assign tasks variables - readonly atRiskCriticalApplicationsCount = signal(0); - readonly totalCriticalApplicationsCount = signal(0); - readonly atRiskCriticalMembersCount = signal(0); + // Used to determine if there are unassigned at-risk cipher IDs + private readonly _tasks!: Signal; + + // Computed properties for selected applications + protected readonly newCriticalApplications = computed(() => { + return this.dialogParams.newApplications.filter((newApp) => + this.selectedApplications().has(newApp.applicationName), + ); + }); + + // New at risk critical applications + protected readonly newAtRiskCriticalApplications = computed(() => { + return this.newCriticalApplications().filter((app) => app.atRiskPasswordCount > 0); + }); + + // Count of unique members with at-risk passwords in newly marked critical applications + protected readonly atRiskCriticalMembersCount = computed(() => { + return getUniqueMembers(this.newCriticalApplications().flatMap((x) => x.atRiskMemberDetails)) + .length; + }); + + protected readonly newUnassignedAtRiskCipherIds = computed(() => { + const newAtRiskCipherIds = this.newCriticalApplications().flatMap((app) => app.atRiskCipherIds); + const tasks = this._tasks(); + + if (tasks.length === 0) { + return newAtRiskCipherIds; + } + + const inProgressTasks = tasks.filter((task) => task.status === SecurityTaskStatus.Pending); + const assignedIdSet = new Set(inProgressTasks.map((task) => task.cipherId)); + const unassignedIds = newAtRiskCipherIds.filter((id) => !assignedIdSet.has(id)); + return unassignedIds; + }); + readonly saving = signal(false); // Loading states @@ -106,13 +141,21 @@ export class NewApplicationsDialogComponent { constructor( @Inject(DIALOG_DATA) protected dialogParams: NewApplicationsDialogData, - private dialogRef: DialogRef, private dataService: RiskInsightsDataService, - private toastService: ToastService, + private dialogRef: DialogRef, + private dialogService: DialogService, private i18nService: I18nService, - private accessIntelligenceSecurityTasksService: AccessIntelligenceSecurityTasksService, + private injector: Injector, private logService: LogService, - ) {} + private securityTasksService: AccessIntelligenceSecurityTasksService, + private toastService: ToastService, + ) { + // Setup the _tasks signal by manually passing in the injector + this._tasks = toSignal(this.securityTasksService.tasks$, { + initialValue: [], + injector: this.injector, + }); + } /** * Opens the new applications dialog @@ -170,53 +213,57 @@ export class NewApplicationsDialogComponent { }); } - handleMarkAsCritical() { - if (this.markingAsCritical() || this.saving()) { - return; // Prevent action if already processing + // Checks if there are selected applications and proceeds to assign tasks + async handleMarkAsCritical() { + if (this.selectedApplications().size === 0) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "confirmNoSelectedCriticalApplicationsTitle" }, + content: { key: "confirmNoSelectedCriticalApplicationsDesc" }, + type: "warning", + }); + + if (!confirmed) { + return; + } } - this.markingAsCritical.set(true); - const onlyNewCriticalApplications = this.dialogParams.newApplications.filter((newApp) => - this.selectedApplications().has(newApp.applicationName), - ); - - // Count only critical applications that have at-risk passwords - const atRiskCriticalApplicationsCount = onlyNewCriticalApplications.filter( - (app) => app.atRiskPasswordCount > 0, - ).length; - this.atRiskCriticalApplicationsCount.set(atRiskCriticalApplicationsCount); - - // Total number of selected critical applications - this.totalCriticalApplicationsCount.set(onlyNewCriticalApplications.length); - - const atRiskCriticalMembersCount = getUniqueMembers( - onlyNewCriticalApplications.flatMap((x) => x.atRiskMemberDetails), - ).length; - this.atRiskCriticalMembersCount.set(atRiskCriticalMembersCount); - - this.currentView.set(DialogView.AssignTasks); - this.markingAsCritical.set(false); + // Skip the assign tasks view if there are no new unassigned at-risk cipher IDs + if (this.newUnassignedAtRiskCipherIds().length === 0) { + this.handleAssignTasks(); + } else { + this.currentView.set(DialogView.AssignTasks); + } } - /** - * Handles the assign tasks button click - */ + // Saves the application review and assigns tasks for unassigned at-risk ciphers protected handleAssignTasks() { if (this.saving()) { return; // Prevent double-click } this.saving.set(true); + const reviewedDate = new Date(); + const updatedApplications = this.dialogParams.newApplications.map((app) => { + const isCritical = this.selectedApplications().has(app.applicationName); + return { + applicationName: app.applicationName, + isCritical, + reviewedDate, + }; + }); + // Save the application review dates and critical markings - this.dataService.criticalApplicationAtRiskCipherIds$ + this.dataService + .saveApplicationReviewStatus(updatedApplications) .pipe( takeUntilDestroyed(this.destroyRef), // Satisfy eslint rule - take(1), // Handle unsubscribe for one off operation - switchMap((criticalApplicationAtRiskCipherIds) => { + take(1), + switchMap(() => { + // Assign password change tasks for unassigned at-risk ciphers for critical applications return from( - this.accessIntelligenceSecurityTasksService.requestPasswordChangeForCriticalApplications( + this.securityTasksService.requestPasswordChangeForCriticalApplications( this.dialogParams.organizationId, - criticalApplicationAtRiskCipherIds, + this.newUnassignedAtRiskCipherIds(), ), ); }), diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html index 0e757582855..04c7bd23797 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/critical-applications/critical-applications.component.html @@ -22,7 +22,7 @@ type="button" class="tw-flex-1" tabindex="0" - (click)="dataService.setDrawerForOrgAtRiskMembers('criticalAppsAtRiskMembers')" + (click)="dataService.setDrawerForCriticalAtRiskMembers('criticalAppsAtRiskMembers')" > + @let status = dataService.reportStatus$ | async; @let hasCiphers = dataService.hasCiphers$ | async; @@ -8,7 +10,6 @@ } @else { @if (isRiskInsightsActivityTabFeatureEnabled && !(dataService.hasReportData$ | async)) { -

{{ "accessIntelligence" | i18n }}

@if (!hasCiphers) { @@ -39,7 +40,6 @@
-

{{ "accessIntelligence" | i18n }}

{{ "reviewAtRiskPasswords" | i18n }}
diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html index d4ab4c5e98f..87a8ee00e05 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.html @@ -14,6 +14,15 @@ }} @if (drawerDetails.atRiskMemberDetails?.length > 0) { +
@@ -77,6 +86,15 @@ }} @if (drawerDetails.atRiskAppDetails?.length > 0) { +
{{ "application" | i18n }} diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts index 2b5910ed99e..9066462b2b1 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.spec.ts @@ -3,8 +3,10 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { mock } from "jest-mock-extended"; import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; import { I18nPipe } from "@bitwarden/ui-common"; import { RiskInsightsDrawerDialogComponent } from "./risk-insights-drawer-dialog.component"; @@ -48,6 +50,8 @@ describe("RiskInsightsDrawerDialogComponent", () => { let component: RiskInsightsDrawerDialogComponent; let fixture: ComponentFixture; const mockI18nService = mock(); + const mockFileDownloadService = mock(); + const mocklogService = mock(); const drawerDetails: DrawerDetails = { open: true, invokerId: "test-invoker", @@ -56,6 +60,7 @@ describe("RiskInsightsDrawerDialogComponent", () => { appAtRiskMembers: null, atRiskAppDetails: null, }; + mockI18nService.t.mockImplementation((key: string) => key); beforeEach(async () => { await TestBed.configureTestingModule({ @@ -64,6 +69,8 @@ describe("RiskInsightsDrawerDialogComponent", () => { { provide: DIALOG_DATA, useValue: drawerDetails }, { provide: I18nPipe, useValue: mock() }, { provide: I18nService, useValue: mockI18nService }, + { provide: FileDownloadService, useValue: mockFileDownloadService }, + { provide: LogService, useValue: mocklogService }, ], }).compileComponents(); @@ -93,4 +100,181 @@ describe("RiskInsightsDrawerDialogComponent", () => { expect(component.isActiveDrawerType(DrawerType.AppAtRiskMembers)).toBeFalsy(); }); }); + describe("downloadAtRiskMembers", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should download CSV when drawer is open with correct type and has data", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [ + { email: "user@example.com", atRiskPasswordCount: 5 }, + { email: "admin@example.com", atRiskPasswordCount: 3 }, + ], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + mockI18nService.t.mockImplementation((key: string) => key); + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("at-risk-members"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should not download when drawer is closed", async () => { + component.drawerDetails = { + open: false, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when activeDrawerType is incorrect", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [{ email: "user@example.com", atRiskPasswordCount: 5 }], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskMemberDetails is null", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskMemberDetails is empty array", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskMembers(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + }); + + describe("downloadAtRiskApplications", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should download CSV when drawer is open with correct type and has data", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [ + { applicationName: "App1", atRiskPasswordCount: 10 }, + { applicationName: "App2", atRiskPasswordCount: 7 }, + ], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).toHaveBeenCalledWith({ + fileName: expect.stringContaining("at-risk-applications"), + blobData: expect.any(String), + blobOptions: { type: "text/plain" }, + }); + }); + + it("should not download when drawer is closed", async () => { + component.drawerDetails = { + open: false, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when activeDrawerType is incorrect", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskMembers, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [{ applicationName: "App1", atRiskPasswordCount: 10 }], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskAppDetails is null", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: null, + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + + it("should not download when atRiskAppDetails is empty array", async () => { + component.drawerDetails = { + open: true, + invokerId: "test-invoker", + activeDrawerType: DrawerType.OrgAtRiskApps, + atRiskMemberDetails: [], + appAtRiskMembers: null, + atRiskAppDetails: [], + }; + + await component.downloadAtRiskApplications(); + + expect(mockFileDownloadService.download).not.toHaveBeenCalled(); + }); + }); }); diff --git a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts index 82cddda542c..30863f38e43 100644 --- a/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/access-intelligence/shared/risk-insights-drawer-dialog.component.ts @@ -1,7 +1,12 @@ import { Component, ChangeDetectionStrategy, Inject } from "@angular/core"; import { DrawerDetails, DrawerType } from "@bitwarden/bit-common/dirt/reports/risk-insights"; +import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { DIALOG_DATA } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; +import { ExportHelper } from "@bitwarden/vault-export-core"; +import { exportToCSV } from "@bitwarden/web-vault/app/dirt/reports/report-utils"; import { SharedModule } from "@bitwarden/web-vault/app/shared"; @Component({ @@ -10,7 +15,12 @@ import { SharedModule } from "@bitwarden/web-vault/app/shared"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class RiskInsightsDrawerDialogComponent { - constructor(@Inject(DIALOG_DATA) public drawerDetails: DrawerDetails) {} + constructor( + @Inject(DIALOG_DATA) public drawerDetails: DrawerDetails, + private fileDownloadService: FileDownloadService, + private i18nService: I18nService, + private logService: LogService, + ) {} // Get a list of drawer types get drawerTypes(): typeof DrawerType { @@ -20,4 +30,62 @@ export class RiskInsightsDrawerDialogComponent { isActiveDrawerType(type: DrawerType): boolean { return this.drawerDetails.activeDrawerType === type; } + + /** + * downloads at risk members as CSV + */ + downloadAtRiskMembers() { + try { + // Validate drawer is open and showing the correct drawer type + if ( + !this.drawerDetails.open || + this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskMembers || + !this.drawerDetails.atRiskMemberDetails || + this.drawerDetails.atRiskMemberDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-members"), + blobData: exportToCSV(this.drawerDetails.atRiskMemberDetails, { + email: this.i18nService.t("email"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk members", error); + } + } + + /** + * downloads at risk applications as CSV + */ + downloadAtRiskApplications() { + try { + // Validate drawer is open and showing the correct drawer type + if ( + !this.drawerDetails.open || + this.drawerDetails.activeDrawerType !== DrawerType.OrgAtRiskApps || + !this.drawerDetails.atRiskAppDetails || + this.drawerDetails.atRiskAppDetails.length === 0 + ) { + return; + } + + this.fileDownloadService.download({ + fileName: ExportHelper.getFileName("at-risk-applications"), + blobData: exportToCSV(this.drawerDetails.atRiskAppDetails, { + applicationName: this.i18nService.t("application"), + atRiskPasswordCount: this.i18nService.t("atRiskPasswords"), + }), + blobOptions: { type: "text/plain" }, + }); + } catch (error) { + // Log error for debugging + this.logService.error("Failed to download at-risk applications", error); + } + } } diff --git a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts index 01e87cae0ed..dcd4fd93cba 100644 --- a/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts +++ b/libs/angular/src/auth/password-management/set-initial-password/default-set-initial-password.service.implementation.ts @@ -182,7 +182,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi if (userKey == null) { masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey); } else { - masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey); + masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey( + masterKey, + userKey, + ); } return masterKeyEncryptedUserKey; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 4215dcf4deb..160b2edba56 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -181,7 +181,9 @@ import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kd import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; +import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service"; import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; +import { DefaultRotateableKeySetService } from "@bitwarden/common/key-management/keys/services/default-rotateable-key-set.service"; import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction, @@ -1737,6 +1739,11 @@ const safeProviders: SafeProvider[] = [ ConfigService, ], }), + safeProvider({ + provide: RotateableKeySetService, + useClass: DefaultRotateableKeySetService, + deps: [KeyService, EncryptService], + }), safeProvider({ provide: NewDeviceVerificationComponentService, useClass: DefaultNewDeviceVerificationComponentService, diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 454a9091c25..2bce4f52f5d 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -43,9 +43,6 @@ export * from "./user-verification/user-verification-dialog.component"; export * from "./user-verification/user-verification-dialog.types"; export * from "./user-verification/user-verification-form-input.component"; -// vault timeout -export * from "./vault-timeout-input/vault-timeout-input.component"; - // sso export * from "./sso/sso.component"; export * from "./sso/sso-component.service"; diff --git a/libs/auth/src/common/models/domain/index.ts b/libs/auth/src/common/models/domain/index.ts index b8b83711a4a..cebfa847569 100644 --- a/libs/auth/src/common/models/domain/index.ts +++ b/libs/auth/src/common/models/domain/index.ts @@ -1,3 +1,2 @@ -export * from "./rotateable-key-set"; export * from "./login-credentials"; export * from "./user-decryption-options"; diff --git a/libs/auth/src/common/models/domain/rotateable-key-set.ts b/libs/auth/src/common/models/domain/rotateable-key-set.ts deleted file mode 100644 index 7578a4e9754..00000000000 --- a/libs/auth/src/common/models/domain/rotateable-key-set.ts +++ /dev/null @@ -1,36 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { PrfKey } from "@bitwarden/common/types/key"; - -declare const tag: unique symbol; - -/** - * A set of keys where a `UserKey` is protected by an encrypted public/private key-pair. - * The `UserKey` is used to encrypt/decrypt data, while the public/private key-pair is - * used to rotate the `UserKey`. - * - * The `PrivateKey` is protected by an `ExternalKey`, such as a `DeviceKey`, or `PrfKey`, - * and the `PublicKey` is protected by the `UserKey`. This setup allows: - * - * - Access to `UserKey` by knowing the `ExternalKey` - * - Rotation to a `NewUserKey` by knowing the current `UserKey`, - * without needing access to the `ExternalKey` - */ -export class RotateableKeySet { - private readonly [tag]: ExternalKey; - - constructor( - /** PublicKey encrypted UserKey */ - readonly encryptedUserKey: EncString, - - /** UserKey encrypted PublicKey */ - readonly encryptedPublicKey: EncString, - - /** ExternalKey encrypted PrivateKey */ - readonly encryptedPrivateKey?: EncString, - ) {} -} - -export type PrfKeySet = RotateableKeySet; diff --git a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts index d47b7ccbcef..191bcaf44a8 100644 --- a/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts +++ b/libs/common/src/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction.ts @@ -11,7 +11,7 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction { /** * Create a symmetric key from the PRF-output by stretching it. - * This should be used as `ExternalKey` with `RotateableKeySet`. + * This should be used as `UpstreamKey` with `RotateableKeySet`. */ abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise; } diff --git a/libs/common/src/auth/models/request/webauthn-rotate-credential.request.ts b/libs/common/src/auth/models/request/webauthn-rotate-credential.request.ts index 2d32d3c21ee..7b831917815 100644 --- a/libs/common/src/auth/models/request/webauthn-rotate-credential.request.ts +++ b/libs/common/src/auth/models/request/webauthn-rotate-credential.request.ts @@ -1,10 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore - -// FIXME: remove `src` and fix import -// eslint-disable-next-line no-restricted-imports -import { RotateableKeySet } from "../../../../../auth/src/common/models"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set"; export class WebauthnRotateCredentialRequest { id: string; diff --git a/libs/common/src/auth/models/response/protected-device.response.ts b/libs/common/src/auth/models/response/protected-device.response.ts index 1c69db8f358..77a077ac1af 100644 --- a/libs/common/src/auth/models/response/protected-device.response.ts +++ b/libs/common/src/auth/models/response/protected-device.response.ts @@ -2,12 +2,9 @@ // @ts-strict-ignore import { Jsonify } from "type-fest"; -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { RotateableKeySet } from "@bitwarden/auth/common"; - import { DeviceType } from "../../../enums"; import { EncString } from "../../../key-management/crypto/models/enc-string"; +import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set"; import { BaseResponse } from "../../../models/response/base.response"; export class ProtectedDeviceResponse extends BaseResponse { diff --git a/libs/common/src/billing/models/response/organization-subscription.response.ts b/libs/common/src/billing/models/response/organization-subscription.response.ts index 6e56eda68c6..f5fdaaba9b2 100644 --- a/libs/common/src/billing/models/response/organization-subscription.response.ts +++ b/libs/common/src/billing/models/response/organization-subscription.response.ts @@ -40,6 +40,7 @@ export class BillingCustomerDiscount extends BaseResponse { id: string; active: boolean; percentOff?: number; + amountOff?: number; appliesTo: string[]; constructor(response: any) { @@ -47,6 +48,7 @@ export class BillingCustomerDiscount extends BaseResponse { this.id = this.getResponseProperty("Id"); this.active = this.getResponseProperty("Active"); this.percentOff = this.getResponseProperty("PercentOff"); - this.appliesTo = this.getResponseProperty("AppliesTo"); + this.amountOff = this.getResponseProperty("AmountOff"); + this.appliesTo = this.getResponseProperty("AppliesTo") || []; } } diff --git a/libs/common/src/billing/models/response/subscription.response.ts b/libs/common/src/billing/models/response/subscription.response.ts index 3bc7d42651c..01ace1ef10a 100644 --- a/libs/common/src/billing/models/response/subscription.response.ts +++ b/libs/common/src/billing/models/response/subscription.response.ts @@ -2,12 +2,15 @@ // @ts-strict-ignore import { BaseResponse } from "../../../models/response/base.response"; +import { BillingCustomerDiscount } from "./organization-subscription.response"; + export class SubscriptionResponse extends BaseResponse { storageName: string; storageGb: number; maxStorageGb: number; subscription: BillingSubscriptionResponse; upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse; + customerDiscount: BillingCustomerDiscount; license: any; expiration: string; @@ -20,11 +23,14 @@ export class SubscriptionResponse extends BaseResponse { this.expiration = this.getResponseProperty("Expiration"); const subscription = this.getResponseProperty("Subscription"); const upcomingInvoice = this.getResponseProperty("UpcomingInvoice"); + const customerDiscount = this.getResponseProperty("CustomerDiscount"); this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription); this.upcomingInvoice = upcomingInvoice == null ? null : new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice); + this.customerDiscount = + customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount); } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2d071259aba..7d2d831bfb3 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -33,6 +33,7 @@ export enum FeatureFlag { PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service", PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog", PM26462_Milestone_3 = "pm-26462-milestone-3", + PM23341_Milestone_2 = "pm-23341-milestone-2", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", @@ -129,6 +130,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE, [FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE, [FeatureFlag.PM26462_Milestone_3]: FALSE, + [FeatureFlag.PM23341_Milestone_2]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts index 58a2c680afa..aa14c7f0c4f 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.implementation.ts @@ -4,7 +4,7 @@ import { firstValueFrom, map, Observable, Subject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; +import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -33,6 +33,7 @@ import { KeyGenerationService } from "../../crypto"; import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service"; import { EncryptService } from "../../crypto/abstractions/encrypt.service"; import { EncString } from "../../crypto/models/enc-string"; +import { RotateableKeySet } from "../../keys/models/rotateable-key-set"; import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction"; /** Uses disk storage so that the device key can persist after log out and tab removal. */ @@ -145,7 +146,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { } // Attempt to get user key - const userKey: UserKey = await this.keyService.getUserKey(userId); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); // If user key is not found, throw error if (!userKey) { @@ -240,7 +241,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction { const request = new OtherDeviceKeysUpdateRequest(); request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString; - request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString; + request.encryptedUserKey = newRotateableKeySet.encapsulatedDownstreamKey.encryptedString; request.deviceId = device.id; return request; }) diff --git a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts index 7ed28518012..e735295f42b 100644 --- a/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts +++ b/libs/common/src/key-management/device-trust/services/device-trust.service.spec.ts @@ -366,7 +366,6 @@ describe("deviceTrustService", () => { let makeDeviceKeySpy: jest.SpyInstance; let rsaGenerateKeyPairSpy: jest.SpyInstance; - let cryptoSvcGetUserKeySpy: jest.SpyInstance; let cryptoSvcRsaEncryptSpy: jest.SpyInstance; let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance; let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance; @@ -402,6 +401,8 @@ describe("deviceTrustService", () => { "mockDeviceKeyEncryptedDevicePrivateKey", ); + keyService.userKey$.mockReturnValue(of(mockUserKey)); + // TypeScript will allow calling private methods if the object is of type 'any' makeDeviceKeySpy = jest .spyOn(deviceTrustService as any, "makeDeviceKey") @@ -411,10 +412,6 @@ describe("deviceTrustService", () => { .spyOn(cryptoFunctionService, "rsaGenerateKeyPair") .mockResolvedValue(mockDeviceRsaKeyPair); - cryptoSvcGetUserKeySpy = jest - .spyOn(keyService, "getUserKey") - .mockResolvedValue(mockUserKey); - cryptoSvcRsaEncryptSpy = jest .spyOn(encryptService, "encapsulateKeyUnsigned") .mockResolvedValue(mockDevicePublicKeyEncryptedUserKey); @@ -448,7 +445,7 @@ describe("deviceTrustService", () => { expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1); expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1); - expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1); + expect(keyService.userKey$).toHaveBeenCalledTimes(1); expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1); @@ -473,18 +470,13 @@ describe("deviceTrustService", () => { }); it("throws specific error if user key is not found", async () => { - // setup the spy to return null - cryptoSvcGetUserKeySpy.mockResolvedValue(null); + keyService.userKey$.mockReturnValueOnce(of(null)); // check if the expected error is thrown await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", ); - // reset the spy - cryptoSvcGetUserKeySpy.mockReset(); - - // setup the spy to return undefined - cryptoSvcGetUserKeySpy.mockResolvedValue(undefined); + keyService.userKey$.mockReturnValueOnce(of(undefined)); // check if the expected error is thrown await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow( "User symmetric key not found", @@ -502,11 +494,6 @@ describe("deviceTrustService", () => { spy: () => rsaGenerateKeyPairSpy, errorText: "rsaGenerateKeyPair error", }, - { - method: "getUserKey", - spy: () => cryptoSvcGetUserKeySpy, - errorText: "getUserKey error", - }, { method: "rsaEncrypt", spy: () => cryptoSvcRsaEncryptSpy, diff --git a/libs/common/src/key-management/keys/models/rotateable-key-set.ts b/libs/common/src/key-management/keys/models/rotateable-key-set.ts new file mode 100644 index 00000000000..630fa2eebba --- /dev/null +++ b/libs/common/src/key-management/keys/models/rotateable-key-set.ts @@ -0,0 +1,34 @@ +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { PrfKey } from "../../../types/key"; +import { EncString } from "../../crypto/models/enc-string"; + +declare const tag: unique symbol; + +/** + * A set of keys where a symmetric `DownstreamKey` is protected by an encrypted public/private key-pair. + * The `DownstreamKey` is used to encrypt/decrypt data, while the public/private key-pair is + * used to rotate the `DownstreamKey`. + * + * The `PrivateKey` is protected by an `UpstreamKey`, such as a `DeviceKey`, or `PrfKey`, + * and the `PublicKey` is protected by the `DownstreamKey`. This setup allows: + * + * - Access to `DownstreamKey` by knowing the `UpstreamKey` + * - Rotation to a new `DownstreamKey` by knowing the current `DownstreamKey`, + * without needing access to the `UpstreamKey` + */ +export class RotateableKeySet { + private readonly [tag]!: UpstreamKey; + + constructor( + /** `DownstreamKey` protected by publicKey */ + readonly encapsulatedDownstreamKey: EncString, + + /** DownstreamKey encrypted PublicKey */ + readonly encryptedPublicKey: EncString, + + /** UpstreamKey encrypted PrivateKey */ + readonly encryptedPrivateKey?: EncString, + ) {} +} + +export type PrfKeySet = RotateableKeySet; diff --git a/libs/common/src/key-management/keys/services/abstractions/rotateable-key-set.service.ts b/libs/common/src/key-management/keys/services/abstractions/rotateable-key-set.service.ts new file mode 100644 index 00000000000..bdb3d56259d --- /dev/null +++ b/libs/common/src/key-management/keys/services/abstractions/rotateable-key-set.service.ts @@ -0,0 +1,30 @@ +import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; +import { RotateableKeySet } from "../../models/rotateable-key-set"; + +export abstract class RotateableKeySetService { + /** + * Create a new rotatable key set for the provided downstreamKey, using the provided upstream key. + * For more information on rotatable key sets, see {@link RotateableKeySet} + * @param upstreamKey The `UpstreamKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey} + * @param downstreamKey The symmetric key to be contained within the `RotateableKeySet`. + * @returns RotateableKeySet containing the provided symmetric downstreamKey. + */ + abstract createKeySet( + upstreamKey: UpstreamKey, + downstreamKey: SymmetricCryptoKey, + ): Promise>; + + /** + * Rotates the provided `RotateableKeySet` with the new key. + * + * @param keySet The current `RotateableKeySet` to be rotated. + * @param oldDownstreamKey The current downstreamKey used to decrypt the `PublicKey`. + * @param newDownstreamKey The new downstreamKey to encrypt the `PublicKey`. + * @returns The updated `RotateableKeySet` that contains the new downstreamKey. + */ + abstract rotateKeySet( + keySet: RotateableKeySet, + oldDownstreamKey: SymmetricCryptoKey, + newDownstreamKey: SymmetricCryptoKey, + ): Promise>; +} diff --git a/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.spec.ts b/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.spec.ts new file mode 100644 index 00000000000..1880bd18b2a --- /dev/null +++ b/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.spec.ts @@ -0,0 +1,185 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; + +import { Utils } from "../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { EncryptService } from "../../crypto/abstractions/encrypt.service"; +import { EncString } from "../../crypto/models/enc-string"; +import { RotateableKeySet } from "../models/rotateable-key-set"; + +import { DefaultRotateableKeySetService } from "./default-rotateable-key-set.service"; + +describe("DefaultRotateableKeySetService", () => { + let keyService!: MockProxy; + let encryptService!: MockProxy; + let service!: DefaultRotateableKeySetService; + + beforeEach(() => { + keyService = mock(); + encryptService = mock(); + service = new DefaultRotateableKeySetService(keyService, encryptService); + }); + + describe("createKeySet", () => { + test.each([null, undefined])( + "throws error when downstreamKey parameter is %s", + async (downstreamKey) => { + const externalKey = createSymmetricKey(); + await expect(service.createKeySet(externalKey, downstreamKey as any)).rejects.toThrow( + "failed to create key set: downstreamKey is required", + ); + }, + ); + + test.each([null, undefined])( + "throws error when upstreamKey parameter is %s", + async (upstreamKey) => { + const userKey = createSymmetricKey(); + await expect(service.createKeySet(upstreamKey as any, userKey)).rejects.toThrow( + "failed to create key set: upstreamKey is required", + ); + }, + ); + + it("should create a new key set", async () => { + const externalKey = createSymmetricKey(); + const userKey = createSymmetricKey(); + const encryptedUserKey = new EncString("encryptedUserKey"); + const encryptedPublicKey = new EncString("encryptedPublicKey"); + const encryptedPrivateKey = new EncString("encryptedPrivateKey"); + keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey]); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey); + encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey); + + const result = await service.createKeySet(externalKey, userKey); + + expect(result).toEqual( + new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey), + ); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(externalKey); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( + userKey, + Utils.fromB64ToArray("publicKey"), + ); + expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith( + Utils.fromB64ToArray("publicKey"), + userKey, + ); + }); + }); + + describe("rotateKeySet", () => { + const keySet = new RotateableKeySet( + new EncString("encUserKey"), + new EncString("encPublicKey"), + new EncString("encPrivateKey"), + ); + const dataValidationTests = [ + { + keySet: null as any as RotateableKeySet, + oldDownstreamKey: createSymmetricKey(), + newDownstreamKey: createSymmetricKey(), + expectedError: "failed to rotate key set: keySet is required", + }, + { + keySet: undefined as any as RotateableKeySet, + oldDownstreamKey: createSymmetricKey(), + newDownstreamKey: createSymmetricKey(), + expectedError: "failed to rotate key set: keySet is required", + }, + { + keySet: keySet, + oldDownstreamKey: null, + newDownstreamKey: createSymmetricKey(), + expectedError: "failed to rotate key set: oldDownstreamKey is required", + }, + { + keySet: keySet, + oldDownstreamKey: undefined, + newDownstreamKey: createSymmetricKey(), + expectedError: "failed to rotate key set: oldDownstreamKey is required", + }, + { + keySet: keySet, + oldDownstreamKey: createSymmetricKey(), + newDownstreamKey: null, + expectedError: "failed to rotate key set: newDownstreamKey is required", + }, + { + keySet: keySet, + oldDownstreamKey: createSymmetricKey(), + newDownstreamKey: undefined, + expectedError: "failed to rotate key set: newDownstreamKey is required", + }, + ]; + + test.each(dataValidationTests)( + "should throw error when required parameter is missing", + async ({ keySet, oldDownstreamKey, newDownstreamKey, expectedError }) => { + await expect( + service.rotateKeySet(keySet, oldDownstreamKey as any, newDownstreamKey as any), + ).rejects.toThrow(expectedError); + }, + ); + + it("throws an error if the public key cannot be decrypted", async () => { + const oldDownstreamKey = createSymmetricKey(); + const newDownstreamKey = createSymmetricKey(); + + encryptService.unwrapEncapsulationKey.mockResolvedValue(null as any); + + await expect( + service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey), + ).rejects.toThrow("failed to rotate key set: could not decrypt public key"); + + expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith( + keySet.encryptedPublicKey, + oldDownstreamKey, + ); + + expect(encryptService.wrapEncapsulationKey).not.toHaveBeenCalled(); + expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled(); + }); + + it("rotates the key set", async () => { + const oldDownstreamKey = createSymmetricKey(); + const newDownstreamKey = new SymmetricCryptoKey(new Uint8Array(64)); + const publicKey = Utils.fromB64ToArray("decryptedPublicKey"); + const newEncryptedPublicKey = new EncString("newEncPublicKey"); + const newEncryptedRotateableKey = new EncString("newEncUserKey"); + + encryptService.unwrapEncapsulationKey.mockResolvedValue(publicKey); + encryptService.wrapEncapsulationKey.mockResolvedValue(newEncryptedPublicKey); + encryptService.encapsulateKeyUnsigned.mockResolvedValue(newEncryptedRotateableKey); + + const result = await service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey); + + expect(result).toEqual( + new RotateableKeySet( + newEncryptedRotateableKey, + newEncryptedPublicKey, + keySet.encryptedPrivateKey, + ), + ); + expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith( + keySet.encryptedPublicKey, + oldDownstreamKey, + ); + expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(publicKey, newDownstreamKey); + expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( + newDownstreamKey, + publicKey, + ); + }); + }); +}); + +function createSymmetricKey() { + const key = Utils.fromB64ToArray( + "1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ", + ); + return new SymmetricCryptoKey(key); +} diff --git a/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.ts b/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.ts new file mode 100644 index 00000000000..3e568cb2aee --- /dev/null +++ b/libs/common/src/key-management/keys/services/default-rotateable-key-set.service.ts @@ -0,0 +1,83 @@ +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; + +import { Utils } from "../../../platform/misc/utils"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { EncryptService } from "../../crypto/abstractions/encrypt.service"; +import { RotateableKeySet } from "../models/rotateable-key-set"; + +import { RotateableKeySetService } from "./abstractions/rotateable-key-set.service"; + +export class DefaultRotateableKeySetService implements RotateableKeySetService { + constructor( + private keyService: KeyService, + private encryptService: EncryptService, + ) {} + + async createKeySet( + upstreamKey: UpstreamKey, + downstreamKey: SymmetricCryptoKey, + ): Promise> { + if (!upstreamKey) { + throw new Error("failed to create key set: upstreamKey is required"); + } + if (!downstreamKey) { + throw new Error("failed to create key set: downstreamKey is required"); + } + + const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(upstreamKey); + + const rawPublicKey = Utils.fromB64ToArray(publicKey); + const encryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned( + downstreamKey, + rawPublicKey, + ); + const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey( + rawPublicKey, + downstreamKey, + ); + return new RotateableKeySet(encryptedRotateableKey, encryptedPublicKey, encryptedPrivateKey); + } + + async rotateKeySet( + keySet: RotateableKeySet, + oldDownstreamKey: SymmetricCryptoKey, + newDownstreamKey: SymmetricCryptoKey, + ): Promise> { + // validate parameters + if (!keySet) { + throw new Error("failed to rotate key set: keySet is required"); + } + if (!oldDownstreamKey) { + throw new Error("failed to rotate key set: oldDownstreamKey is required"); + } + if (!newDownstreamKey) { + throw new Error("failed to rotate key set: newDownstreamKey is required"); + } + + const publicKey = await this.encryptService.unwrapEncapsulationKey( + keySet.encryptedPublicKey, + oldDownstreamKey, + ); + if (publicKey == null) { + throw new Error("failed to rotate key set: could not decrypt public key"); + } + const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey( + publicKey, + newDownstreamKey, + ); + const newEncryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned( + newDownstreamKey, + publicKey, + ); + + const newRotateableKeySet = new RotateableKeySet( + newEncryptedRotateableKey, + newEncryptedPublicKey, + keySet.encryptedPrivateKey, + ); + + return newRotateableKeySet; + } +} diff --git a/libs/components/src/badge-list/badge-list.component.html b/libs/components/src/badge-list/badge-list.component.html index 18365cba268..d976b2d2cc4 100644 --- a/libs/components/src/badge-list/badge-list.component.html +++ b/libs/components/src/badge-list/badge-list.component.html @@ -1,15 +1,15 @@
- @for (item of filteredItems; track item; let last = $last) { + @for (item of filteredItems(); track item; let last = $last) { {{ item }} - @if (!last || isFiltered) { + @if (!last || isFiltered()) { , } } - @if (isFiltered) { + @if (isFiltered()) { - {{ "plusNMore" | i18n: (items().length - filteredItems.length).toString() }} + {{ "plusNMore" | i18n: (items().length - filteredItems().length).toString() }} }
diff --git a/libs/components/src/badge-list/badge-list.component.ts b/libs/components/src/badge-list/badge-list.component.ts index e3d1403be43..a5b306c12fc 100644 --- a/libs/components/src/badge-list/badge-list.component.ts +++ b/libs/components/src/badge-list/badge-list.component.ts @@ -1,38 +1,60 @@ -import { Component, OnChanges, input } from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; import { I18nPipe } from "@bitwarden/ui-common"; import { BadgeModule, BadgeVariant } from "../badge"; function transformMaxItems(value: number | undefined) { - return value == undefined ? undefined : Math.max(1, value); + return value == null ? undefined : Math.max(1, value); } -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +/** + * Displays a collection of badges in a horizontal, wrapping layout. + * + * The component automatically handles overflow by showing a limited number of badges + * followed by a "+N more" badge when `maxItems` is specified and exceeded. + * + * Each badge inherits the `variant` and `truncate` settings, ensuring visual consistency + * across the list. Badges are separated by commas for screen readers to improve accessibility. + */ @Component({ selector: "bit-badge-list", templateUrl: "badge-list.component.html", imports: [BadgeModule, I18nPipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BadgeListComponent implements OnChanges { - protected filteredItems: string[] = []; - protected isFiltered = false; - +export class BadgeListComponent { + /** + * The visual variant to apply to all badges in the list. + */ readonly variant = input("primary"); + + /** + * Items to display as badges. + */ readonly items = input([]); + + /** + * Whether to truncate long badge text with ellipsis. + */ readonly truncate = input(true); + /** + * Maximum number of badges to display before showing a "+N more" badge. + */ readonly maxItems = input(undefined, { transform: transformMaxItems }); - ngOnChanges() { + protected readonly filteredItems = computed(() => { const maxItems = this.maxItems(); + const items = this.items(); - if (maxItems == undefined || this.items().length <= maxItems) { - this.filteredItems = this.items(); - } else { - this.filteredItems = this.items().slice(0, maxItems - 1); + if (maxItems == null || items.length <= maxItems) { + return items; } - this.isFiltered = this.items().length > this.filteredItems.length; - } + return items.slice(0, maxItems - 1); + }); + + protected readonly isFiltered = computed(() => { + return this.items().length > this.filteredItems().length; + }); } diff --git a/libs/components/src/badge/badge.component.ts b/libs/components/src/badge/badge.component.ts index 8a953b30226..55d7b719ccd 100644 --- a/libs/components/src/badge/badge.component.ts +++ b/libs/components/src/badge/badge.component.ts @@ -1,5 +1,12 @@ import { CommonModule } from "@angular/common"; -import { Component, ElementRef, HostBinding, input } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, +} from "@angular/core"; import { FocusableElement } from "../shared/focusable-element"; @@ -44,27 +51,56 @@ const hoverStyles: Record = { ], }; /** - * Badges are primarily used as labels, counters, and small buttons. - - * Typically Badges are only used with text set to `text-xs`. If additional sizes are needed, the component configurations may be reviewed and adjusted. - - * The Badge directive can be used on a `` (non clickable events), or an `` or `