From 5967cf05394c8ef65c4a7c4cd6039e6d8a32b940 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:09:20 -0500 Subject: [PATCH] [PM-14571] At Risk Passwords - Badge Update (#15983) * add exclamation badge for at risk passwords on tab * add berry icon for the badge when pending tasks are present * remove integration wtih autofill for pending task badge * add ability to override Never match strategy - This is helpful for non-autofill purposes but cipher matching is still needed. This will default to the domain. * add at-risk-cipher badge updater service * Revert "add exclamation badge for at risk passwords on tab" This reverts commit a9643c03d5ff812a88d554b4a4bcb13f0d5444f0. * remove nullish-coalescing * ensure that all user related observables use the same user.id --------- Co-authored-by: Shane Melton --- .../browser/src/background/main.background.ts | 11 ++ apps/browser/src/images/berry19.png | Bin 0 -> 1702 bytes apps/browser/src/images/berry38.png | Bin 0 -> 1244 bytes apps/browser/src/platform/badge/icon.ts | 4 + ...-risk-cipher-badge-updater.service.spec.ts | 84 +++++++++ .../at-risk-cipher-badge-updater.service.ts | 163 ++++++++++++++++++ .../src/vault/abstractions/cipher.service.ts | 4 + .../vault/models/view/login-uri-view.spec.ts | 27 +++ .../src/vault/models/view/login-uri.view.ts | 8 + .../src/vault/models/view/login.view.ts | 6 +- .../src/vault/services/cipher.service.ts | 11 +- .../src/vault/utils/cipher-view-like-utils.ts | 10 +- 12 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 apps/browser/src/images/berry19.png create mode 100644 apps/browser/src/images/berry38.png create mode 100644 apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts create mode 100644 apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index df29502edeb..75481dde8cf 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -302,6 +302,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; +import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service"; import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; @@ -433,6 +434,7 @@ export default class MainBackground { badgeService: BadgeService; authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService; autofillBadgeUpdaterService: AutofillBadgeUpdaterService; + atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -1838,6 +1840,14 @@ export default class MainBackground { this.logService, ); + this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService( + this.badgeService, + this.accountService, + this.cipherService, + this.logService, + this.taskService, + ); + this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -1847,6 +1857,7 @@ export default class MainBackground { await this.overlayBackground.init(); await this.tabsBackground.init(); await this.autofillBadgeUpdaterService.init(); + await this.atRiskCipherUpdaterService.init(); } generatePassword = async (): Promise => { diff --git a/apps/browser/src/images/berry19.png b/apps/browser/src/images/berry19.png new file mode 100644 index 0000000000000000000000000000000000000000..51deb3b8d68de97ce8521d376b2823eaecd12824 GIT binary patch literal 1702 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQmUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lxRtf@J#ddWzYh$IT%lpi<;HsXMd|v6mX?>t*GR!Iveo=5iVsfgTA=EA;AtcoxS6lfPWu^iH6clVA| zx>w8XSLgoSaxLxF3?HMJAMbFkT&z^}F!kM@-|uD?zqi%r+Vb-D{cEorudZS)mCp_3 zxjyx6ebmHvYKs|5%@cRFtF6}%^>770$x;Qincd%%qGvf;DyTuzVOG?w^o5@-lCZhs!@`GSXQiR(1)tS*D? zcMi;1d8*>$O_!C9GHIgKx{s!B-dR(~XI^sc$p*Pz2HR$_YuD?Ul3KU~-um%quH1UL=|5_p-ww1rUeG%8%l%@voCVvQ4u72O`6f%&;X3=O z$=h-q1<%RN>7L;8v0w3IZKVDWmtj-N zt%Hw$NL1NHbeOI^$a3#s<@~3cCLX$-;~PA8nt^d_R+{tovb!Adt&>}4f3{oQ^2$W% zc|_HJb@q)~vbBFL-$br`7*cF`Ozz0uOD%!EkKNXm+zl!@-fp&Ga|44GultXn!{PZF zoAiI_EZp{Ed#e_6#j%g?pSsN6<{B7#Qf{NkmdKI;Vst08`R>5C8xG literal 0 HcmV?d00001 diff --git a/apps/browser/src/images/berry38.png b/apps/browser/src/images/berry38.png new file mode 100644 index 0000000000000000000000000000000000000000..44a670637010dc0a565f5d67efc796a85120dff2 GIT binary patch literal 1244 zcmV<21S9*2P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91CZGcV1ONa40RR91CIA2c0EF@&TL1tAMoC0LR9Fe^SZ{1oRTTfdx4Mem z6p^it$ow!Mgds+VZkS<&7>yWbB9yU2gBlYt86Wf$Ve-Xx>G5o2Djl1@R`JQnC0jx!x`_Laj{S}O;&>>XJdb3kNb2O)Ik;{0LTHI3M)sXX$bS~G zfsh1vmU`j!-G#cRny|WUDNfc0(DK$*NiO>lOjAszfUZx*O09q^|FWyr}8!^Pvhhj^NfP*I@`1V|kw*?97rrEbdX6mQS?<4ke(9)kaVwhF> z(04{+jJEZSQT#eAKaeehn+@BVT(YKu`+96irTs`Q_(6~Kp|2xik*gXuc8y@L0CIOW z86O<1DW?+p*ubv1yT%zY2EG_h;jvxAIPpvF*3O*RJGTPwba>&ZtCs3?p=V;O?$cP$ zf@T81a>)tNc4C?<^L|l-M*c?H|LI-J&MO zvrmR)^=^3FMs*}cjj(J9P9=?v1tU763^{eo#C_d+1#7lnz`IAoNT#ySRE~!Sj5fnQ zy0_+A{E__Ov;YV=4sc3EX`wbk { + let service: AtRiskCipherBadgeUpdaterService; + + let setState: jest.Mock; + let clearState: jest.Mock; + let warning: jest.Mock; + let getAllDecryptedForUrl: jest.Mock; + let getTab: jest.Mock; + let addListener: jest.Mock; + + const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + const cipherViews$ = new BehaviorSubject([]); + const pendingTasks$ = new BehaviorSubject([]); + const userId = "test-user-id" as UserId; + + beforeEach(async () => { + setState = jest.fn().mockResolvedValue(undefined); + clearState = jest.fn().mockResolvedValue(undefined); + warning = jest.fn(); + getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); + getTab = jest.fn(); + addListener = jest.fn(); + + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); + jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); + + service = new AtRiskCipherBadgeUpdaterService( + { setState, clearState } as unknown as BadgeService, + { activeAccount$ } as unknown as AccountService, + { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { warning } as unknown as LogService, + { pendingTasks$ } as unknown as TaskService, + ); + + await service.init(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("clears the tab state when there are no ciphers and no pending tasks", async () => { + const tab = { id: 1 } as chrome.tabs.Tab; + + await service["setTabState"](tab, userId, []); + + expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + }); + + it("sets state when there are pending tasks for the tab", async () => { + const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "at-risk-cipher-badge-3", + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + text: Unset, + backgroundColor: Unset, + }, + 3, + ); + }); +}); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts new file mode 100644 index 00000000000..47364958ad8 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -0,0 +1,163 @@ +import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; + +export class AtRiskCipherBadgeUpdaterService { + private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); + private tabUpdated$ = new Subject(); + private tabRemoved$ = new Subject(); + private tabActivated$ = new Subject(); + + private activeUserData$ = this.accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + of(user.id), + this.taskService + .pendingTasks$(user.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()), + ]), + ), + ); + + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private cipherService: CipherService, + private logService: LogService, + private taskService: TaskService, + ) { + combineLatest({ + replaced: this.tabReplaced$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { + await this.clearTabState(replaced.removedTabId); + await this.setTabState(replaced.addedTab, userId, pendingTasks); + }), + ) + .subscribe(() => {}); + + combineLatest({ + tab: this.tabActivated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + combineLatest({ + tab: this.tabUpdated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + this.tabRemoved$ + .pipe( + mergeMap(async (tabId) => { + await this.clearTabState(tabId); + }), + ) + .subscribe(); + } + + init() { + BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { + const newTab = await BrowserApi.getTab(addedTabId); + if (!newTab) { + this.logService.warning( + `Tab replaced event received but new tab not found (id: ${addedTabId})`, + ); + return; + } + + this.tabReplaced$.next({ + removedTabId, + addedTab: newTab, + }); + }); + + BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { + if (changeInfo.url) { + this.tabUpdated$.next(tab); + } + }); + + BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { + const tab = await BrowserApi.getTab(activeInfo.tabId); + if (!tab) { + this.logService.warning( + `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, + ); + return; + } + + this.tabActivated$.next(tab); + }); + + BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); + } + + /** Sets the pending task state for the tab */ + private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { + if (!tab.id) { + this.logService.warning("Tab event received but tab id is undefined"); + return; + } + + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; + + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + await this.clearTabState(tab.id); + return; + } + + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + tab.id, + ); + } + + /** Clears the pending task state from a tab */ + private async clearTabState(tabId: number) { + await this.badgeService.clearState(StateName(tabId)); + } +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2f4fcf0ef51..7eb2d4b0656 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract filterCiphersForUrl( ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, + /** When true, will override the match strategy for the cipher if it is Never. */ + overrideNeverMatchStrategy?: true, ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index 155d3d59f7c..aae9438df2e 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -111,6 +111,33 @@ describe("LoginUriView", () => { expect(actual).toBe(false); }); + + it("overrides Never match strategy with Domain when parameter is set", () => { + const loginUri = new LoginUriView(); + loginUri.uri = "https://example.org"; + loginUri.match = UriMatchStrategy.Never; + + expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true); + expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false); + }); + + it("overrides Never match strategy when passed in as default strategy", () => { + const loginUriNoMatch = new LoginUriView(); + loginUriNoMatch.uri = "https://example.org"; + + expect( + loginUriNoMatch.matchesUri( + "https://example.org", + new Set(), + UriMatchStrategy.Never, + true, + ), + ).toBe(true); + + expect( + loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never), + ).toBe(false); + }); }); describe("using host matching", () => { diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 38cd517e542..49ac9c6278f 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -142,6 +142,8 @@ export class LoginUriView implements View { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (!this.uri || !targetUri) { return false; @@ -150,6 +152,12 @@ export class LoginUriView implements View { let matchType = this.match ?? defaultUriMatch; matchType ??= UriMatchStrategy.Domain; + // Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true. + // This is useful in scenarios when the cipher should be matched to rely other information other than autofill. + if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) { + matchType = UriMatchStrategy.Domain; + } + const targetDomain = Utils.getDomain(targetUri); const matchDomains = equivalentDomains.add(targetDomain); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index d268cf4afaa..44c6ee8f2e9 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -82,12 +82,16 @@ export class LoginView extends ItemView { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (this.uris == null) { return false; } - return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch)); + return this.uris.some((uri) => + uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), + ); } static fromJSON(obj: Partial>): LoginView { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d89a41aba1f..f6e12e71edd 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { return await firstValueFrom( this.cipherViews$(userId).pipe( @@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction { url, includeOtherTypes, defaultMatch, + overrideNeverMatchStrategy, ), ), ), @@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction { url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { if (url == null && includeOtherTypes == null) { return []; @@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction { } if (cipherIsLogin) { - return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri( + cipher, + url, + equivalentDomains, + defaultMatch, + overrideNeverMatchStrategy, + ); } return false; diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 1c7a4382a04..5ef1d9bdc75 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -174,13 +174,19 @@ export class CipherViewLikeUtils { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + overrideNeverMatchStrategy?: true, ): boolean => { if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { return false; } if (!this.isCipherListView(cipher)) { - return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + return cipher.login.matchesUri( + targetUri, + equivalentDomains, + defaultUriMatch, + overrideNeverMatchStrategy, + ); } const login = this.getLogin(cipher); @@ -198,7 +204,7 @@ export class CipherViewLikeUtils { }); return loginUriViews.some((uriView) => - uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), ); };