diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index c2e9ef60d8c..de438a09467 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -4910,6 +4910,42 @@ "beta": { "message": "Beta" }, + "importantNotice": { + "message": "Important notice" + }, + "setupTwoStepLogin": { + "message": "Set up two-step login" + }, + "newDeviceVerificationNoticeContentPage1": { + "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + }, + "newDeviceVerificationNoticeContentPage2": { + "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + }, + "remindMeLater": { + "message": "Remind me later" + }, + "newDeviceVerificationNoticePageOneFormContent": { + "message": "Do you have reliable access to your email, $EMAIL$?", + "placeholders": { + "email": { + "content": "$1", + "example": "your_name@email.com" + } + } + }, + "newDeviceVerificationNoticePageOneEmailAccessNo": { + "message": "No, I do not" + }, + "newDeviceVerificationNoticePageOneEmailAccessYes": { + "message": "Yes, I can reliably access my email" + }, + "turnOnTwoStepLogin": { + "message": "Turn on two-step login" + }, + "changeAcctEmail": { + "message": "Change account email" + }, "extensionWidth": { "message": "Extension width" }, diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 99ed4619954..144af0c0a35 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -25,6 +25,7 @@ import { BrowserScriptInjectorService } from "../../../platform/services/browser import { AbortManager } from "../../../vault/background/abort-manager"; import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum"; import { Fido2PortName } from "../enums/fido2-port-name.enum"; +import { BrowserFido2ParentWindowReference } from "../services/browser-fido2-user-interface.service"; import { Fido2ExtensionMessage } from "./abstractions/fido2.background"; import { Fido2Background } from "./fido2.background"; @@ -56,7 +57,7 @@ describe("Fido2Background", () => { let senderMock!: MockProxy; let logService!: MockProxy; let fido2ActiveRequestManager: MockProxy; - let fido2ClientService!: MockProxy; + let fido2ClientService!: MockProxy>; let vaultSettingsService!: MockProxy; let scriptInjectorServiceMock!: MockProxy; let configServiceMock!: MockProxy; @@ -73,7 +74,7 @@ describe("Fido2Background", () => { }); senderMock = mock({ id: "1", tab: tabMock }); logService = mock(); - fido2ClientService = mock(); + fido2ClientService = mock>(); vaultSettingsService = mock(); abortManagerMock = mock(); abortController = mock(); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index f84b7d29a66..e20a0584d20 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -23,10 +23,11 @@ import { ScriptInjectorService } from "../../../platform/services/abstractions/s import { AbortManager } from "../../../vault/background/abort-manager"; import { Fido2ContentScript, Fido2ContentScriptId } from "../enums/fido2-content-script.enum"; import { Fido2PortName } from "../enums/fido2-port-name.enum"; +import { BrowserFido2ParentWindowReference } from "../services/browser-fido2-user-interface.service"; import { - Fido2Background as Fido2BackgroundInterface, Fido2BackgroundExtensionMessageHandlers, + Fido2Background as Fido2BackgroundInterface, Fido2ExtensionMessage, SharedFido2ScriptInjectionDetails, SharedFido2ScriptRegistrationOptions, @@ -56,7 +57,7 @@ export class Fido2Background implements Fido2BackgroundInterface { constructor( private logService: LogService, private fido2ActiveRequestManager: Fido2ActiveRequestManager, - private fido2ClientService: Fido2ClientService, + private fido2ClientService: Fido2ClientService, private vaultSettingsService: VaultSettingsService, private scriptInjectorService: ScriptInjectorService, private configService: ConfigService, diff --git a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts index 872bb1bb52a..04b09a7df32 100644 --- a/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts +++ b/apps/browser/src/autofill/fido2/services/browser-fido2-user-interface.service.ts @@ -111,11 +111,15 @@ export type BrowserFido2Message = { sessionId: string } & ( } ); +export type BrowserFido2ParentWindowReference = chrome.tabs.Tab; + /** * Browser implementation of the {@link Fido2UserInterfaceService}. * The user interface is implemented as a popout and the service uses the browser's messaging API to communicate with it. */ -export class BrowserFido2UserInterfaceService implements Fido2UserInterfaceServiceAbstraction { +export class BrowserFido2UserInterfaceService + implements Fido2UserInterfaceServiceAbstraction +{ constructor(private authService: AuthService) {} async newSession( diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 019be2923b6..616e18601af 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -201,11 +201,11 @@ import { ImportServiceAbstraction, } from "@bitwarden/importer/core"; import { - DefaultKdfConfigService, - KdfConfigService, BiometricStateService, BiometricsService, DefaultBiometricStateService, + DefaultKdfConfigService, + KdfConfigService, KeyService as KeyServiceAbstraction, } from "@bitwarden/key-management"; import { @@ -232,7 +232,10 @@ import { MainContextMenuHandler } from "../autofill/browser/main-context-menu-ha import LegacyOverlayBackground from "../autofill/deprecated/background/overlay.background.deprecated"; import { Fido2Background as Fido2BackgroundAbstraction } from "../autofill/fido2/background/abstractions/fido2.background"; import { Fido2Background } from "../autofill/fido2/background/fido2.background"; -import { BrowserFido2UserInterfaceService } from "../autofill/fido2/services/browser-fido2-user-interface.service"; +import { + BrowserFido2ParentWindowReference, + BrowserFido2UserInterfaceService, +} from "../autofill/fido2/services/browser-fido2-user-interface.service"; import { AutofillService as AutofillServiceAbstraction } from "../autofill/services/abstractions/autofill.service"; import AutofillService from "../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service"; @@ -337,10 +340,10 @@ export default class MainBackground { policyApiService: PolicyApiServiceAbstraction; sendApiService: SendApiServiceAbstraction; userVerificationApiService: UserVerificationApiServiceAbstraction; - fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; - fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; + fido2UserInterfaceService: Fido2UserInterfaceServiceAbstraction; + fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction; fido2ActiveRequestManager: Fido2ActiveRequestManagerAbstraction; - fido2ClientService: Fido2ClientServiceAbstraction; + fido2ClientService: Fido2ClientServiceAbstraction; avatarService: AvatarServiceAbstraction; mainContextMenuHandler: MainContextMenuHandler; cipherContextMenuHandler: CipherContextMenuHandler; @@ -1330,7 +1333,7 @@ export default class MainBackground { return new Promise((resolve) => { setTimeout(async () => { await this.refreshBadge(); - await this.fullSync(true); + await this.fullSync(false); this.taskSchedulerService.setInterval( ScheduledTaskNames.scheduleNextSyncInterval, 5 * 60 * 1000, // check every 5 minutes diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 3ec2667cd8c..ad839bbd7ce 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -19,6 +19,7 @@ import { import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; +import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, @@ -43,6 +44,11 @@ import { TwoFactorTimeoutIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { + NewDeviceVerificationNoticePageOneComponent, + NewDeviceVerificationNoticePageTwoComponent, + VaultIcons, +} from "@bitwarden/vault"; import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard"; @@ -715,6 +721,33 @@ const routes: Routes = [ canActivate: [authGuard], data: { elevation: 2 } satisfies RouteDataProperties, }, + { + path: "new-device-notice", + component: ExtensionAnonLayoutWrapperComponent, + canActivate: [], + children: [ + { + path: "", + component: NewDeviceVerificationNoticePageOneComponent, + data: { + pageIcon: VaultIcons.ExclamationTriangle, + pageTitle: { + key: "importantNotice", + }, + }, + }, + { + path: "setup", + component: NewDeviceVerificationNoticePageTwoComponent, + data: { + pageIcon: VaultIcons.UserLock, + pageTitle: { + key: "setupTwoStepLogin", + }, + }, + }, + ], + }, ...extensionRefreshSwap(TabsComponent, TabsV2Component, { path: "tabs", data: { elevation: 0 } satisfies RouteDataProperties, @@ -734,7 +767,7 @@ const routes: Routes = [ }, ...extensionRefreshSwap(VaultFilterComponent, VaultV2Component, { path: "vault", - canActivate: [authGuard], + canActivate: [authGuard, NewDeviceVerificationNoticeGuard], canDeactivate: [clearVaultStateGuard], data: { elevation: 0 } satisfies RouteDataProperties, }), diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html index d4ff0662fe0..b1f01bb9cb0 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.html @@ -1,8 +1,8 @@ - + - + diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts index a375aba302e..9ac17b49386 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.spec.ts @@ -1,27 +1,40 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing"; import { ActivatedRoute } from "@angular/router"; import { mock } from "jest-mock-extended"; -import { Subject } from "rxjs"; +import { BehaviorSubject, Subject } from "rxjs"; import { WINDOW } from "@bitwarden/angular/services/injection-tokens"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, 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 { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service"; import { PasswordHistoryV2Component } from "./vault-password-history-v2.component"; describe("PasswordHistoryV2Component", () => { - let component: PasswordHistoryV2Component; let fixture: ComponentFixture; const params$ = new Subject(); + + const mockCipherView = { + id: "111-222-333", + name: "cipher one", + } as CipherView; + + const mockCipher = { + decrypt: jest.fn().mockResolvedValue(mockCipherView), + } as unknown as Cipher; + const back = jest.fn().mockResolvedValue(undefined); + const getCipher = jest.fn().mockResolvedValue(mockCipher); beforeEach(async () => { back.mockClear(); + getCipher.mockClear(); await TestBed.configureTestingModule({ imports: [PasswordHistoryV2Component], @@ -29,8 +42,13 @@ describe("PasswordHistoryV2Component", () => { { provide: WINDOW, useValue: window }, { provide: PlatformUtilsService, useValue: mock() }, { provide: ConfigService, useValue: mock() }, - { provide: CipherService, useValue: mock() }, - { provide: AccountService, useValue: mock() }, + { provide: CipherService, useValue: mock({ get: getCipher }) }, + { + provide: AccountService, + useValue: mock({ + activeAccount$: new BehaviorSubject({ id: "acct-1" } as Account), + }), + }, { provide: PopupRouterCacheService, useValue: { back } }, { provide: ActivatedRoute, useValue: { queryParams: params$ } }, { provide: I18nService, useValue: { t: (key: string) => key } }, @@ -38,19 +56,21 @@ describe("PasswordHistoryV2Component", () => { }).compileComponents(); fixture = TestBed.createComponent(PasswordHistoryV2Component); - component = fixture.componentInstance; fixture.detectChanges(); }); - it("sets the cipherId from the params", () => { - params$.next({ cipherId: "444-33-33-1111" }); + it("loads the cipher from params the cipherId from the params", fakeAsync(() => { + params$.next({ cipherId: mockCipherView.id }); - expect(component["cipherId"]).toBe("444-33-33-1111"); - }); + tick(100); + + expect(getCipher).toHaveBeenCalledWith(mockCipherView.id); + })); it("navigates back when a cipherId is not in the params", () => { params$.next({}); expect(back).toHaveBeenCalledTimes(1); + expect(getCipher).not.toHaveBeenCalled(); }); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts index c70c83f40fc..c8f590ced57 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-password-history-v2/vault-password-history-v2.component.ts @@ -3,10 +3,14 @@ import { NgIf } from "@angular/common"; import { Component, OnInit } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; -import { first } from "rxjs/operators"; +import { firstValueFrom } from "rxjs"; +import { first, map } from "rxjs/operators"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { CipherId } from "@bitwarden/common/types/guid"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordHistoryViewComponent } from "../../../../../../../../libs/vault/src/components/password-history-view/password-history-view.component"; import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component"; @@ -28,18 +32,20 @@ import { PopupRouterCacheService } from "../../../../../platform/popup/view-cach ], }) export class PasswordHistoryV2Component implements OnInit { - protected cipherId: CipherId; + protected cipher: CipherView; constructor( private browserRouterHistory: PopupRouterCacheService, private route: ActivatedRoute, + private cipherService: CipherService, + private accountService: AccountService, ) {} ngOnInit() { // eslint-disable-next-line rxjs-angular/prefer-takeuntil this.route.queryParams.pipe(first()).subscribe((params) => { if (params.cipherId) { - this.cipherId = params.cipherId; + void this.loadCipher(params.cipherId); } else { this.close(); } @@ -49,4 +55,22 @@ export class PasswordHistoryV2Component implements OnInit { close() { void this.browserRouterHistory.back(); } + + /** Load the cipher based on the given Id */ + private async loadCipher(cipherId: string) { + const cipher = await this.cipherService.get(cipherId); + + const activeAccount = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a: { id: string | undefined }) => a)), + ); + + if (!activeAccount?.id) { + throw new Error("Active account is not available."); + } + + const activeUserId = activeAccount.id as UserId; + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + } } diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts index ded4686477e..5024b960d9c 100644 --- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.spec.ts @@ -2,6 +2,8 @@ import { TestBed } from "@angular/core/testing"; import { Router } from "@angular/router"; import { mock, MockProxy } from "jest-mock-extended"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + import { BrowserViewPasswordHistoryService } from "./browser-view-password-history.service"; describe("BrowserViewPasswordHistoryService", () => { @@ -19,9 +21,9 @@ describe("BrowserViewPasswordHistoryService", () => { describe("viewPasswordHistory", () => { it("navigates to the password history screen", async () => { - await service.viewPasswordHistory("test"); + await service.viewPasswordHistory({ id: "cipher-id" } as CipherView); expect(router.navigate).toHaveBeenCalledWith(["/cipher-password-history"], { - queryParams: { cipherId: "test" }, + queryParams: { cipherId: "cipher-id" }, }); }); }); diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts index 453fe113ebf..5e400da9de5 100644 --- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts @@ -4,6 +4,7 @@ import { inject } from "@angular/core"; import { Router } from "@angular/router"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; /** * This class handles the premium upgrade process for the browser extension. @@ -14,7 +15,9 @@ export class BrowserViewPasswordHistoryService implements ViewPasswordHistorySer /** * Navigates to the password history screen. */ - async viewPasswordHistory(cipherId: string) { - await this.router.navigate(["/cipher-password-history"], { queryParams: { cipherId } }); + async viewPasswordHistory(cipher: CipherView) { + await this.router.navigate(["/cipher-password-history"], { + queryParams: { cipherId: cipher.id }, + }); } } diff --git a/apps/cli/package.json b/apps/cli/package.json index a0ab4c3f74d..b7d54e78e1d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -80,7 +80,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.66", + "tldts": "6.1.69", "zxcvbn": "4.4.2" } } diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 09d3d15e897..1fac307262a 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -62,12 +62,55 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + [[package]] name = "anyhow" version = "1.0.94" @@ -103,6 +146,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + [[package]] name = "async-broadcast" version = "0.7.1" @@ -318,6 +402,15 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "basic-toml" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8" +dependencies = [ + "serde", +] + [[package]] name = "bcrypt-pbkdf" version = "0.10.0" @@ -329,6 +422,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -422,6 +524,38 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "cbc" version = "0.1.2" @@ -487,6 +621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69371e34337c4c984bbe322360c2547210bf632eb2814bbe78a6e87a2935bd2b" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -495,11 +630,24 @@ version = "4.5.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e24c1b4099818523236a8ca881d2b45db98dadfb4625cf6608c12069fcbbde1" dependencies = [ + "anstream", "anstyle", "clap_lex", "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "clap_lex" version = "0.7.3" @@ -525,6 +673,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -724,6 +878,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.9" @@ -815,6 +982,8 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "serde", + "serde_json", "tokio", "tokio-stream", "tokio-util", @@ -1035,6 +1204,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f81ec6369c545a7d40e4589b5597581fa1c441fe1cce96dd1de43159910a36a2" +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + [[package]] name = "futures" version = "0.3.31" @@ -1190,12 +1368,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.4.0" @@ -1245,7 +1446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.2", ] [[package]] @@ -1273,6 +1474,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.14" @@ -1372,6 +1579,21 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "macos_provider" +version = "0.0.0" +dependencies = [ + "desktop_core", + "futures", + "log", + "oslog", + "serde", + "serde_json", + "tokio", + "tokio-util", + "uniffi", +] + [[package]] name = "md-5" version = "0.10.6" @@ -1397,6 +1619,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1811,6 +2049,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + [[package]] name = "parking" version = "2.2.1" @@ -1851,6 +2100,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1967,6 +2222,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "polling" version = "3.7.4" @@ -2235,6 +2496,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "salsa20" version = "0.10.2" @@ -2262,6 +2529,26 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f81c2fde025af7e69b1d1420531c8a8811ca898919db177141a85313b1cb932" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "scrypt" version = "0.11.0" @@ -2301,6 +2588,9 @@ name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -2322,6 +2612,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -2391,6 +2693,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" @@ -2406,6 +2714,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.8" @@ -2544,6 +2858,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "smawk", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2648,6 +2971,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "0.6.8" @@ -2726,6 +3058,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-ident" version = "1.0.14" @@ -2744,6 +3082,136 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "uniffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb08c58c7ed7033150132febe696bef553f891b1ede57424b40d87a89e3c170" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cade167af943e189a55020eda2c314681e223f1e42aca7c4e52614c2b627698f" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "once_cell", + "paste", + "serde", + "textwrap", + "toml", + "uniffi_meta", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7cf32576e08104b7dc2a6a5d815f37616e66c6866c2a639fe16e6d2286b75b" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_checksum_derive" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "802d2051a700e3ec894c79f80d2705b69d85844dafbbe5d1a92776f8f48b563a" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "uniffi_core" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7687007d2546c454d8ae609b105daceb88175477dac280707ad6d95bcd6f1f" +dependencies = [ + "anyhow", + "bytes", + "log", + "once_cell", + "paste", + "static_assertions", +] + +[[package]] +name = "uniffi_macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c65a5b12ec544ef136693af8759fb9d11aefce740fb76916721e876639033b" +dependencies = [ + "bincode", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a74ed96c26882dac1ca9b93ca23c827e284bacbd7ec23c6f0b0372f747d59e4" +dependencies = [ + "anyhow", + "bytes", + "siphasher", + "uniffi_checksum_derive", +] + +[[package]] +name = "uniffi_testing" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6f984f0781f892cc864a62c3a5c60361b1ccbd68e538e6c9fbced5d82268ac" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "fs-err", + "once_cell", +] + +[[package]] +name = "uniffi_udl" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037820a4cfc4422db1eaa82f291a3863c92c7d1789dc513489c36223f9b4cdfc" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "uniffi_testing", + "weedle2", +] + [[package]] name = "universal-hash" version = "0.5.1" @@ -2754,6 +3222,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -2839,6 +3313,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + [[package]] name = "widestring" version = "1.1.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 6525b38162d..6230a6bfe15 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["napi", "core", "proxy"] +members = ["napi", "core", "proxy", "macos_provider"] diff --git a/apps/desktop/desktop_native/macos_provider/.gitignore b/apps/desktop/desktop_native/macos_provider/.gitignore new file mode 100644 index 00000000000..73f0a6381d8 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/.gitignore @@ -0,0 +1 @@ +BitwardenMacosProviderFFI.xcframework diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml new file mode 100644 index 00000000000..28cc6372c62 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "macos_provider" +license = "GPL-3.0" +version = "0.0.0" +edition = "2021" +publish = false + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" + +[lib] +crate-type = ["staticlib", "cdylib"] +bench = false + +[dependencies] +desktop_core = { path = "../core" } +futures = "=0.3.31" +log = "0.4.22" +serde = { version = "1.0.205", features = ["derive"] } +serde_json = "1.0.122" +tokio = { version = "1.39.2", features = ["sync"] } +tokio-util = "0.7.11" +uniffi = { version = "0.28.0", features = ["cli"] } + +[target.'cfg(target_os = "macos")'.dependencies] +oslog = "0.2.0" + +[build-dependencies] +uniffi = { version = "0.28.0", features = ["build"] } diff --git a/apps/desktop/desktop_native/macos_provider/build.sh b/apps/desktop/desktop_native/macos_provider/build.sh new file mode 100755 index 00000000000..21e2e045af4 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +cd "$(dirname "$0")" + +rm -r BitwardenMacosProviderFFI.xcframework +rm -r tmp + +mkdir -p ./tmp/target/universal-darwin/release/ + + +cargo build --package macos_provider --target aarch64-apple-darwin --release +cargo build --package macos_provider --target x86_64-apple-darwin --release + +# Create universal libraries +lipo -create ../target/aarch64-apple-darwin/release/libmacos_provider.a \ + ../target/x86_64-apple-darwin/release/libmacos_provider.a \ + -output ./tmp/target/universal-darwin/release/libmacos_provider.a + +# Generate swift bindings +cargo run --bin uniffi-bindgen --features uniffi/cli generate \ + ../target/aarch64-apple-darwin/release/libmacos_provider.dylib \ + --library \ + --language swift \ + --no-format \ + --out-dir tmp/bindings + +# Move generated swift bindings +mkdir -p ../../macos/autofill-extension/ +mv ./tmp/bindings/*.swift ../../macos/autofill-extension/ + +# Massage the generated files to fit xcframework +mkdir tmp/Headers +mv ./tmp/bindings/*.h ./tmp/Headers/ +cat ./tmp/bindings/*.modulemap > ./tmp/Headers/module.modulemap + +# Build xcframework +xcodebuild -create-xcframework \ + -library ./tmp/target/universal-darwin/release/libmacos_provider.a \ + -headers ./tmp/Headers \ + -output ./BitwardenMacosProviderFFI.xcframework + +# Cleanup temporary files +rm -r tmp diff --git a/apps/desktop/desktop_native/macos_provider/src/assertion.rs b/apps/desktop/desktop_native/macos_provider/src/assertion.rs new file mode 100644 index 00000000000..762ceaaed48 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/src/assertion.rs @@ -0,0 +1,46 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, UserVerification}; + +#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionRequest { + rp_id: String, + credential_id: Vec, + user_name: String, + user_handle: Vec, + record_identifier: Option, + client_data_hash: Vec, + user_verification: UserVerification, +} + +#[derive(uniffi::Record, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionResponse { + rp_id: String, + user_handle: Vec, + signature: Vec, + client_data_hash: Vec, + authenticator_data: Vec, + credential_id: Vec, +} + +#[uniffi::export(with_foreign)] +pub trait PreparePasskeyAssertionCallback: Send + Sync { + fn on_complete(&self, credential: PasskeyAssertionResponse); + fn on_error(&self, error: BitwardenError); +} + +impl Callback for Arc { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { + let credential = serde_json::from_value(credential)?; + PreparePasskeyAssertionCallback::on_complete(self.as_ref(), credential); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + PreparePasskeyAssertionCallback::on_error(self.as_ref(), error); + } +} diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs new file mode 100644 index 00000000000..5623436d874 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -0,0 +1,205 @@ +#![cfg(target_os = "macos")] + +use std::{ + collections::HashMap, + sync::{atomic::AtomicU32, Arc, Mutex}, + time::Instant, +}; + +use futures::FutureExt; +use log::{error, info}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +uniffi::setup_scaffolding!(); + +mod assertion; +mod registration; + +use assertion::{PasskeyAssertionRequest, PreparePasskeyAssertionCallback}; +use registration::{PasskeyRegistrationRequest, PreparePasskeyRegistrationCallback}; + +#[derive(uniffi::Enum, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum UserVerification { + Preferred, + Required, + Discouraged, +} + +#[derive(Debug, uniffi::Error, Serialize, Deserialize)] +pub enum BitwardenError { + Internal(String), +} + +// TODO: These have to be named differently than the actual Uniffi traits otherwise +// the generated code will lead to ambiguous trait implementations +// These are only used internally, so it doesn't matter that much +trait Callback: Send + Sync { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error>; + fn error(&self, error: BitwardenError); +} + +#[derive(uniffi::Object)] +pub struct MacOSProviderClient { + to_server_send: tokio::sync::mpsc::Sender, + + // We need to keep track of the callbacks so we can call them when we receive a response + response_callbacks_counter: AtomicU32, + #[allow(clippy::type_complexity)] + response_callbacks_queue: Arc, Instant)>>>, +} + +#[uniffi::export] +impl MacOSProviderClient { + #[uniffi::constructor] + pub fn connect() -> Self { + let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension") + .level_filter(log::LevelFilter::Trace) + .init(); + + let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32); + let (to_server_send, to_server_recv) = tokio::sync::mpsc::channel(32); + + let client = MacOSProviderClient { + to_server_send, + response_callbacks_counter: AtomicU32::new(0), + response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), + }; + + let path = desktop_core::ipc::path("autofill"); + + let queue = client.response_callbacks_queue.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Can't create runtime"); + + rt.spawn( + desktop_core::ipc::client::connect(path, from_server_send, to_server_recv) + .map(|r| r.map_err(|e| e.to_string())), + ); + + rt.block_on(async move { + while let Some(message) = from_server_recv.recv().await { + match serde_json::from_str::(&message) { + Ok(SerializedMessage::Command(CommandMessage::Connected)) => { + info!("Connected to server"); + } + Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { + info!("Disconnected from server"); + } + Ok(SerializedMessage::Message { + sequence_number, + value, + }) => match queue.lock().unwrap().remove(&sequence_number) { + Some((cb, request_start_time)) => { + info!( + "Time to process request: {:?}", + request_start_time.elapsed() + ); + match value { + Ok(value) => { + if let Err(e) = cb.complete(value) { + error!("Error deserializing message: {e}"); + } + } + Err(e) => { + error!("Error processing message: {e:?}"); + cb.error(e) + } + } + } + None => { + error!("No callback found for sequence number: {sequence_number}") + } + }, + Err(e) => { + error!("Error deserializing message: {e}"); + } + }; + } + }); + }); + + client + } + + pub fn prepare_passkey_registration( + &self, + request: PasskeyRegistrationRequest, + callback: Arc, + ) { + self.send_message(request, Box::new(callback)); + } + + pub fn prepare_passkey_assertion( + &self, + request: PasskeyAssertionRequest, + callback: Arc, + ) { + self.send_message(request, Box::new(callback)); + } +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "command", rename_all = "camelCase")] +enum CommandMessage { + Connected, + Disconnected, +} + +#[derive(Serialize, Deserialize)] +#[serde(untagged, rename_all = "camelCase")] +enum SerializedMessage { + Command(CommandMessage), + Message { + sequence_number: u32, + value: Result, + }, +} + +impl MacOSProviderClient { + fn add_callback(&self, callback: Box) -> u32 { + let sequence_number = self + .response_callbacks_counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + self.response_callbacks_queue + .lock() + .unwrap() + .insert(sequence_number, (callback, Instant::now())); + + sequence_number + } + + fn send_message( + &self, + message: impl Serialize + DeserializeOwned, + callback: Box, + ) { + let sequence_number = self.add_callback(callback); + + let message = serde_json::to_string(&SerializedMessage::Message { + sequence_number, + value: Ok(serde_json::to_value(message).unwrap()), + }) + .expect("Can't serialize message"); + + if let Err(e) = self.to_server_send.blocking_send(message) { + // Make sure we remove the callback from the queue if we can't send the message + if let Some((cb, _)) = self + .response_callbacks_queue + .lock() + .unwrap() + .remove(&sequence_number) + { + cb.error(BitwardenError::Internal(format!( + "Error sending message: {}", + e + ))); + } + } + } +} diff --git a/apps/desktop/desktop_native/macos_provider/src/registration.rs b/apps/desktop/desktop_native/macos_provider/src/registration.rs new file mode 100644 index 00000000000..d484af58b6c --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/src/registration.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; + +use crate::{BitwardenError, Callback, UserVerification}; + +#[derive(uniffi::Record, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationRequest { + rp_id: String, + user_name: String, + user_handle: Vec, + client_data_hash: Vec, + user_verification: UserVerification, + supported_algorithms: Vec, +} + +#[derive(uniffi::Record, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationResponse { + rp_id: String, + client_data_hash: Vec, + credential_id: Vec, + attestation_object: Vec, +} + +#[uniffi::export(with_foreign)] +pub trait PreparePasskeyRegistrationCallback: Send + Sync { + fn on_complete(&self, credential: PasskeyRegistrationResponse); + fn on_error(&self, error: BitwardenError); +} + +impl Callback for Arc { + fn complete(&self, credential: serde_json::Value) -> Result<(), serde_json::Error> { + let credential = serde_json::from_value(credential)?; + PreparePasskeyRegistrationCallback::on_complete(self.as_ref(), credential); + Ok(()) + } + + fn error(&self, error: BitwardenError) { + PreparePasskeyRegistrationCallback::on_error(self.as_ref(), error); + } +} diff --git a/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs b/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs new file mode 100644 index 00000000000..f6cff6cf1d9 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/apps/desktop/desktop_native/macos_provider/uniffi.toml b/apps/desktop/desktop_native/macos_provider/uniffi.toml new file mode 100644 index 00000000000..ba696b8ec15 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/uniffi.toml @@ -0,0 +1,4 @@ +[bindings.swift] +ffi_module_name = "BitwardenMacosProviderFFI" +module_name = "BitwardenMacosProvider" +generate_immutable_records = true diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 08664eb6a53..6a656cdc574 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -20,6 +20,8 @@ anyhow = "=1.0.94" desktop_core = { path = "../core" } napi = { version = "=2.16.13", features = ["async"] } napi-derive = "=2.16.13" +serde = { version = "1.0.209", features = ["derive"] } +serde_json = "1.0.127" tokio = { version = "=1.41.1" } tokio-util = "=0.7.12" tokio-stream = "=0.1.15" diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index b884829e77d..1f37563e4fe 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -124,6 +124,58 @@ export declare namespace ipc { } export declare namespace autofill { export function runCommand(value: string): Promise + export const enum UserVerification { + Preferred = 'preferred', + Required = 'required', + Discouraged = 'discouraged' + } + export interface PasskeyRegistrationRequest { + rpId: string + userName: string + userHandle: Array + clientDataHash: Array + userVerification: UserVerification + supportedAlgorithms: Array + } + export interface PasskeyRegistrationResponse { + rpId: string + clientDataHash: Array + credentialId: Array + attestationObject: Array + } + export interface PasskeyAssertionRequest { + rpId: string + credentialId: Array + userName: string + userHandle: Array + recordIdentifier?: string + clientDataHash: Array + userVerification: UserVerification + } + export interface PasskeyAssertionResponse { + rpId: string + userHandle: Array + signature: Array + clientDataHash: Array + authenticatorData: Array + credentialId: Array + } + export class IpcServer { + /** + * Create and start the IPC server without blocking. + * + * @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. + * @param callback This function will be called whenever a message is received from a client. + */ + static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void): Promise + /** Return the path to the IPC server. */ + getPath(): string + /** Stop the IPC server. */ + stop(): void + completeRegistration(clientId: number, sequenceNumber: number, response: PasskeyRegistrationResponse): number + completeAssertion(clientId: number, sequenceNumber: number, response: PasskeyAssertionResponse): number + completeError(clientId: number, sequenceNumber: number, error: string): number + } } export declare namespace crypto { export function argon2(secret: Buffer, salt: Buffer, iterations: number, memory: number, parallelism: number): Promise diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index a7e2144b1dc..170d7bca4f9 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -545,12 +545,256 @@ pub mod ipc { #[napi] pub mod autofill { + use desktop_core::ipc::server::{Message, MessageType}; + use napi::threadsafe_function::{ + ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode, + }; + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + #[napi] pub async fn run_command(value: String) -> napi::Result { desktop_core::autofill::run_command(value) .await .map_err(|e| napi::Error::from_reason(e.to_string())) } + + #[derive(Debug, serde::Serialize, serde:: Deserialize)] + pub enum BitwardenError { + Internal(String), + } + + #[napi(string_enum)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub enum UserVerification { + #[napi(value = "preferred")] + Preferred, + #[napi(value = "required")] + Required, + #[napi(value = "discouraged")] + Discouraged, + } + + #[derive(Serialize, Deserialize)] + #[serde(bound = "T: Serialize + DeserializeOwned")] + pub struct PasskeyMessage { + pub sequence_number: u32, + pub value: Result, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationRequest { + pub rp_id: String, + pub user_name: String, + pub user_handle: Vec, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + pub supported_algorithms: Vec, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyRegistrationResponse { + pub rp_id: String, + pub client_data_hash: Vec, + pub credential_id: Vec, + pub attestation_object: Vec, + } + + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionRequest { + pub rp_id: String, + pub credential_id: Vec, + pub user_name: String, + pub user_handle: Vec, + pub record_identifier: Option, + pub client_data_hash: Vec, + pub user_verification: UserVerification, + } + + #[napi(object)] + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct PasskeyAssertionResponse { + pub rp_id: String, + pub user_handle: Vec, + pub signature: Vec, + pub client_data_hash: Vec, + pub authenticator_data: Vec, + pub credential_id: Vec, + } + + #[napi] + pub struct IpcServer { + server: desktop_core::ipc::server::Server, + } + + #[napi] + impl IpcServer { + /// Create and start the IPC server without blocking. + /// + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. + /// @param callback This function will be called whenever a message is received from a client. + #[napi(factory)] + pub async fn listen( + name: String, + // Ideally we'd have a single callback that has an enum containing the request values, + // but NAPI doesn't support that just yet + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void" + )] + registration_callback: ThreadsafeFunction< + (u32, u32, PasskeyRegistrationRequest), + ErrorStrategy::CalleeHandled, + >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void" + )] + assertion_callback: ThreadsafeFunction< + (u32, u32, PasskeyAssertionRequest), + ErrorStrategy::CalleeHandled, + >, + ) -> napi::Result { + let (send, mut recv) = tokio::sync::mpsc::channel::(32); + tokio::spawn(async move { + while let Some(Message { + client_id, + kind, + message, + }) = recv.recv().await + { + match kind { + // TODO: We're ignoring the connection and disconnection messages for now + MessageType::Connected | MessageType::Disconnected => continue, + MessageType::Message => { + let Some(message) = message else { + println!("[ERROR] Message is empty"); + continue; + }; + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + + assertion_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + println!("[ERROR] Error deserializing message1: {e}"); + } + } + + match serde_json::from_str::>( + &message, + ) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + registration_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(e) => { + println!("[ERROR] Error deserializing message2: {e}"); + } + } + + println!("[ERROR] Received an unknown message2: {message:?}"); + } + } + } + }); + + let path = desktop_core::ipc::path(&name); + + let server = desktop_core::ipc::server::Server::start(&path, send).map_err(|e| { + napi::Error::from_reason(format!( + "Error listening to server - Path: {path:?} - Error: {e} - {e:?}" + )) + })?; + + Ok(IpcServer { server }) + } + + /// Return the path to the IPC server. + #[napi] + pub fn get_path(&self) -> String { + self.server.path.to_string_lossy().to_string() + } + + /// Stop the IPC server. + #[napi] + pub fn stop(&self) -> napi::Result<()> { + self.server.stop(); + Ok(()) + } + + #[napi] + pub fn complete_registration( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyRegistrationResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_assertion( + &self, + client_id: u32, + sequence_number: u32, + response: PasskeyAssertionResponse, + ) -> napi::Result { + let message = PasskeyMessage { + sequence_number, + value: Ok(response), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + #[napi] + pub fn complete_error( + &self, + client_id: u32, + sequence_number: u32, + error: String, + ) -> napi::Result { + let message: PasskeyMessage<()> = PasskeyMessage { + sequence_number, + value: Err(BitwardenError::Internal(error)), + }; + self.send(client_id, serde_json::to_string(&message).unwrap()) + } + + // TODO: Add a way to send a message to a specific client? + fn send(&self, _client_id: u32, message: String) -> napi::Result { + self.server + .send(message) + .map_err(|e| { + napi::Error::from_reason(format!("Error sending message - Error: {e} - {e:?}")) + }) + // NAPI doesn't support u64 or usize, so we need to convert to u32 + .map(|u| u32::try_from(u).unwrap_or_default()) + } + } } #[napi] diff --git a/apps/desktop/macos/.gitignore b/apps/desktop/macos/.gitignore new file mode 100644 index 00000000000..e5d4324b213 --- /dev/null +++ b/apps/desktop/macos/.gitignore @@ -0,0 +1 @@ +BitwardenMacosProvider.swift diff --git a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift index 4d03bf97e6c..dbaa8517086 100644 --- a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift +++ b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift @@ -9,8 +9,46 @@ import AuthenticationServices import os class CredentialProviderViewController: ASCredentialProviderViewController { - let logger = Logger() + let logger: Logger + + // There is something a bit strange about the initialization/deinitialization in this class. + // Sometimes deinit won't be called after a request has successfully finished, + // which would leave this class hanging in memory and the IPC connection open. + // + // If instead I make this a static, the deinit gets called correctly after each request. + // I think we still might want a static regardless, to be able to reuse the connection if possible. + static let client: MacOsProviderClient = { + let instance = MacOsProviderClient.connect() + // setup code + return instance + }() + + init() { + logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + + logger.log("[autofill-extension] initializing extension") + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + logger.log("[autofill-extension] deinitializing extension") + } + + + @IBAction func cancel(_ sender: AnyObject?) { + self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + } + + @IBAction func passwordSelected(_ sender: AnyObject?) { + let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") + self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) + } + /* Implement this method if your extension supports showing credentials in the QuickType bar. When the user selects a credential from your app, this method will be called with the @@ -21,7 +59,14 @@ class CredentialProviderViewController: ASCredentialProviderViewController { */ + // Deprecated override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction called \(credentialIdentity)") + logger.log("[autofill-extension] user \(credentialIdentity.user)") + logger.log("[autofill-extension] id \(credentialIdentity.recordIdentifier ?? "")") + logger.log("[autofill-extension] sid \(credentialIdentity.serviceIdentifier.identifier)") + logger.log("[autofill-extension] sidt \(credentialIdentity.serviceIdentifier.type.rawValue)") + // let databaseIsUnlocked = true // if (databaseIsUnlocked) { let passwordCredential = ASPasswordCredential(user: credentialIdentity.user, password: "example1234") @@ -31,6 +76,67 @@ class CredentialProviderViewController: ASCredentialProviderViewController { // } } + override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { + if let request = credentialRequest as? ASPasskeyCredentialRequest { + if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity { + + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)") + + class CallbackImpl: PreparePasskeyAssertionCallback { + let ctx: ASCredentialProviderExtensionContext + required init(_ ctx: ASCredentialProviderExtensionContext) { + self.ctx = ctx + } + + func onComplete(credential: PasskeyAssertionResponse) { + ctx.completeAssertionRequest(using: ASPasskeyAssertionCredential( + userHandle: credential.userHandle, + relyingParty: credential.rpId, + signature: credential.signature, + clientDataHash: credential.clientDataHash, + authenticatorData: credential.authenticatorData, + credentialID: credential.credentialId + )) + } + + func onError(error: BitwardenError) { + ctx.cancelRequest(withError: error) + } + } + + let userVerification = switch request.userVerificationPreference { + case .preferred: + UserVerification.preferred + case .required: + UserVerification.required + default: + UserVerification.discouraged + } + + let req = PasskeyAssertionRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + credentialId: passkeyIdentity.credentialID, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + recordIdentifier: passkeyIdentity.recordIdentifier, + clientDataHash: request.clientDataHash, + userVerification: userVerification + ) + + CredentialProviderViewController.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext)) + return + } + } + + if let request = credentialRequest as? ASPasswordCredentialRequest { + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(password) called \(request)") + return; + } + + logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2 called wrong") + self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request")) + } + /* Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with ASExtensionError.userInteractionRequired. In this case, the system may present your extension's @@ -41,34 +147,65 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } */ - @IBAction func cancel(_ sender: AnyObject?) { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) - } - @IBAction func passwordSelected(_ sender: AnyObject?) { - let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } - override func prepareInterfaceForExtensionConfiguration() { logger.log("[autofill-extension] prepareInterfaceForExtensionConfiguration called") } override func prepareInterface(forPasskeyRegistration registrationRequest: ASCredentialRequest) { - logger.log("[autofill-extension] prepare interface for registration request \(registrationRequest.description)") + if let request = registrationRequest as? ASPasskeyCredentialRequest { + if let passkeyIdentity = registrationRequest.credentialIdentity as? ASPasskeyCredentialIdentity { + class CallbackImpl: PreparePasskeyRegistrationCallback { + let ctx: ASCredentialProviderExtensionContext + required init(_ ctx: ASCredentialProviderExtensionContext) { + self.ctx = ctx + } -// self.extensionContext.cancelRequest(withError: ExampleError.nope) - } + func onComplete(credential: PasskeyRegistrationResponse) { + ctx.completeRegistrationRequest(using: ASPasskeyRegistrationCredential( + relyingParty: credential.rpId, + clientDataHash: credential.clientDataHash, + credentialID: credential.credentialId, + attestationObject: credential.attestationObject + )) + } - override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) { - logger.log("[autofill-extension] prepare interface for credential request \(credentialRequest.description)") + func onError(error: BitwardenError) { + ctx.cancelRequest(withError: error) + } + } + + let userVerification = switch request.userVerificationPreference { + case .preferred: + UserVerification.preferred + case .required: + UserVerification.required + default: + UserVerification.discouraged + } + + let req = PasskeyRegistrationRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + clientDataHash: request.clientDataHash, + userVerification: userVerification, + supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) } + ) + CredentialProviderViewController.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext)) + return + } + } + + // If we didn't get a passkey, return an error + self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid registration request")) } /* - Prepare your UI to list available credentials for the user to choose from. The items in - 'serviceIdentifiers' describe the service the user is logging in to, so your extension can - prioritize the most relevant credentials in the list. - */ + Prepare your UI to list available credentials for the user to choose from. The items in + 'serviceIdentifiers' describe the service the user is logging in to, so your extension can + prioritize the most relevant credentials in the list. + */ override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { logger.log("[autofill-extension] prepareCredentialList for serviceIdentifiers: \(serviceIdentifiers.count)") @@ -77,18 +214,13 @@ class CredentialProviderViewController: ASCredentialProviderViewController { } } - override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { - logger.log("[autofill-extension] prepareInterfaceToProvideCredential for credentialIdentity: \(credentialIdentity.user)") - } - override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters) { logger.log("[autofill-extension] prepareCredentialList(passkey) for serviceIdentifiers: \(serviceIdentifiers.count)") - + logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)") + for serviceIdentifier in serviceIdentifiers { logger.log(" service: \(serviceIdentifier.identifier)") } - - logger.log("request parameters: \(requestParameters.relyingPartyIdentifier)") } } diff --git a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements index 2e600a8d529..86c7195768e 100644 --- a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements +++ b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements @@ -6,5 +6,9 @@ com.apple.security.app-sandbox + com.apple.security.application-groups + + LTZ2PFU5D6.com.bitwarden.desktop + diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj index 313b158895c..2ac467f3289 100644 --- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj +++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj @@ -7,12 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; }; + 3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; }; E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; }; E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; }; E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = ""; }; + 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = ""; }; 968ED08A2C52A47200FFFEE6 /* Production.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Production.xcconfig; sourceTree = ""; }; E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = System/Library/Frameworks/AuthenticationServices.framework; sourceTree = SDKROOT; }; @@ -28,6 +32,7 @@ buildActionMask = 2147483647; files = ( E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */, + 3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -56,6 +61,7 @@ isa = PBXGroup; children = ( E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */, + 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */, ); name = Frameworks; sourceTree = ""; @@ -63,6 +69,7 @@ E1DF71402B342F6900F29026 /* autofill-extension */ = { isa = PBXGroup; children = ( + 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */, E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */, E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */, E1DF71462B342F6900F29026 /* Info.plist */, @@ -140,6 +147,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */, E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 99953603e45..10d5ded3448 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,7 +23,7 @@ "build:dev": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\"", "build:preload": "cross-env NODE_ENV=production webpack --config webpack.preload.js", "build:preload:watch": "cross-env NODE_ENV=production webpack --config webpack.preload.js --watch", - "build:macos-extension": "node scripts/build-macos-extension.js", + "build:macos-extension": "./desktop_native/macos_provider/build.sh && node scripts/build-macos-extension.js", "build:main": "cross-env NODE_ENV=production webpack --config webpack.main.js", "build:main:dev": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js", "build:main:watch": "npm run build-native && cross-env NODE_ENV=development webpack --config webpack.main.js --watch", diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 21dced5c2aa..c7642638dc3 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -16,6 +16,7 @@ import { } from "@bitwarden/angular/auth/guards"; import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-refresh-redirect"; +import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, @@ -40,6 +41,11 @@ import { TwoFactorTimeoutIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { + NewDeviceVerificationNoticePageOneComponent, + NewDeviceVerificationNoticePageTwoComponent, + VaultIcons, +} from "@bitwarden/vault"; import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; @@ -116,10 +122,37 @@ const routes: Routes = [ } satisfies RouteDataProperties & AnonLayoutWrapperData, }, { path: "register", component: RegisterComponent }, + { + path: "new-device-notice", + component: AnonLayoutWrapperComponent, + canActivate: [], + children: [ + { + path: "", + component: NewDeviceVerificationNoticePageOneComponent, + data: { + pageIcon: VaultIcons.ExclamationTriangle, + pageTitle: { + key: "importantNotice", + }, + }, + }, + { + path: "setup", + component: NewDeviceVerificationNoticePageTwoComponent, + data: { + pageIcon: VaultIcons.UserLock, + pageTitle: { + key: "setupTwoStepLogin", + }, + }, + }, + ], + }, { path: "vault", component: VaultComponent, - canActivate: [authGuard], + canActivate: [authGuard, NewDeviceVerificationNoticeGuard], }, { path: "accessibility-cookie", component: AccessibilityCookieComponent }, { path: "set-password", component: SetPasswordComponent }, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index ccce1e3bd7c..0f541907995 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -56,6 +56,8 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; +import { Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction } from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service"; @@ -73,6 +75,7 @@ import { Message, MessageListener, MessageSender } from "@bitwarden/common/platf // eslint-disable-next-line no-restricted-imports -- Used for dependency injection import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal"; import { TaskSchedulerService } from "@bitwarden/common/platform/scheduling"; +import { Fido2AuthenticatorService } from "@bitwarden/common/platform/services/fido2/fido2-authenticator.service"; import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service"; import { DefaultSdkClientFactory } from "@bitwarden/common/platform/services/sdk/default-sdk-client-factory"; import { NoopSdkClientFactory } from "@bitwarden/common/platform/services/sdk/noop-sdk-client-factory"; @@ -80,6 +83,7 @@ import { SystemService } from "@bitwarden/common/platform/services/system.servic import { GlobalStateProvider, StateProvider } from "@bitwarden/common/platform/state"; // eslint-disable-next-line import/no-restricted-paths -- Implementation for memory storage import { MemoryStorageService as MemoryStorageServiceForStateProviders } from "@bitwarden/common/platform/state/storage/memory-storage.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service"; import { VaultTimeoutStringType } from "@bitwarden/common/types/vault-timeout.type"; import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -97,6 +101,7 @@ import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-l import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service"; +import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; @@ -309,7 +314,29 @@ const safeProviders: SafeProvider[] = [ }), safeProvider({ provide: DesktopAutofillService, - deps: [LogService, CipherServiceAbstraction, ConfigService], + deps: [ + LogService, + CipherServiceAbstraction, + ConfigService, + Fido2AuthenticatorServiceAbstraction, + AccountService, + ], + }), + safeProvider({ + provide: Fido2UserInterfaceServiceAbstraction, + useClass: DesktopFido2UserInterfaceService, + deps: [AuthServiceAbstraction, CipherServiceAbstraction, AccountService, LogService], + }), + safeProvider({ + provide: Fido2AuthenticatorServiceAbstraction, + useClass: Fido2AuthenticatorService, + deps: [ + CipherServiceAbstraction, + Fido2UserInterfaceServiceAbstraction, + SyncService, + AccountService, + LogService, + ], }), safeProvider({ provide: NativeMessagingManifestService, diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index 9ce5e1319fd..494544f5858 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -1,9 +1,92 @@ import { ipcRenderer } from "electron"; +import type { autofill } from "@bitwarden/desktop-napi"; + import { Command } from "../platform/main/autofill/command"; import { RunCommandParams, RunCommandResult } from "../platform/main/autofill/native-autofill.main"; export default { runCommand: (params: RunCommandParams): Promise> => ipcRenderer.invoke("autofill.runCommand", params), + + listenPasskeyRegistration: ( + fn: ( + clientId: number, + sequenceNumber: number, + request: autofill.PasskeyRegistrationRequest, + completeCallback: ( + error: Error | null, + response: autofill.PasskeyRegistrationResponse, + ) => void, + ) => void, + ) => { + ipcRenderer.on( + "autofill.passkeyRegistration", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + request: autofill.PasskeyRegistrationRequest; + }, + ) => { + const { clientId, sequenceNumber, request } = data; + fn(clientId, sequenceNumber, request, (error, response) => { + if (error) { + ipcRenderer.send("autofill.completeError", { + clientId, + sequenceNumber, + error: error.message, + }); + return; + } + + ipcRenderer.send("autofill.completePasskeyRegistration", { + clientId, + sequenceNumber, + response, + }); + }); + }, + ); + }, + + listenPasskeyAssertion: ( + fn: ( + clientId: number, + sequenceNumber: number, + request: autofill.PasskeyAssertionRequest, + completeCallback: (error: Error | null, response: autofill.PasskeyAssertionResponse) => void, + ) => void, + ) => { + ipcRenderer.on( + "autofill.passkeyAssertion", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + request: autofill.PasskeyAssertionRequest; + }, + ) => { + const { clientId, sequenceNumber, request } = data; + fn(clientId, sequenceNumber, request, (error, response) => { + if (error) { + ipcRenderer.send("autofill.completeError", { + clientId, + sequenceNumber, + error: error.message, + }); + return; + } + + ipcRenderer.send("autofill.completePasskeyAssertion", { + clientId, + sequenceNumber, + response, + }); + }); + }, + ); + }, }; diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 8c5dd597850..1ce58596b34 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -1,12 +1,32 @@ import { Injectable, OnDestroy } from "@angular/core"; -import { EMPTY, Subject, distinctUntilChanged, mergeMap, switchMap, takeUntil } from "rxjs"; +import { autofill } from "desktop_native/napi"; +import { + EMPTY, + Subject, + distinctUntilChanged, + firstValueFrom, + map, + mergeMap, + switchMap, + takeUntil, +} from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + Fido2AuthenticatorGetAssertionParams, + Fido2AuthenticatorGetAssertionResult, + Fido2AuthenticatorMakeCredentialResult, + Fido2AuthenticatorMakeCredentialsParams, + Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction, +} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { guidToRawFormat } from "@bitwarden/common/platform/services/fido2/guid-utils"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -26,6 +46,8 @@ export class DesktopAutofillService implements OnDestroy { private logService: LogService, private cipherService: CipherService, private configService: ConfigService, + private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, + private accountService: AccountService, ) {} async init() { @@ -47,6 +69,8 @@ export class DesktopAutofillService implements OnDestroy { takeUntil(this.destroy$), ) .subscribe(); + + this.listenIpc(); } /** Give metadata about all available credentials in the users vault */ @@ -114,6 +138,146 @@ export class DesktopAutofillService implements OnDestroy { }); } + listenIpc() { + ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => { + this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); + this.logService.warning( + "listenPasskeyRegistration2", + this.convertRegistrationRequest(request), + ); + + const controller = new AbortController(); + void this.fido2AuthenticatorService + .makeCredential(this.convertRegistrationRequest(request), null, controller) + .then((response) => { + callback(null, this.convertRegistrationResponse(request, response)); + }) + .catch((error) => { + this.logService.error("listenPasskeyRegistration error", error); + callback(error, null); + }); + }); + + ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => { + this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request); + + // TODO: For some reason the credentialId is passed as an empty array in the request, so we need to + // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. + if (request.recordIdentifier && request.credentialId.length === 0) { + const cipher = await this.cipherService.get(request.recordIdentifier); + if (!cipher) { + this.logService.error("listenPasskeyAssertion error", "Cipher not found"); + callback(new Error("Cipher not found"), null); + return; + } + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const decrypted = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), + ); + + const fido2Credential = decrypted.login.fido2Credentials?.[0]; + if (!fido2Credential) { + this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); + callback(new Error("Fido2Credential not found"), null); + return; + } + + request.credentialId = Array.from( + guidToRawFormat(decrypted.login.fido2Credentials?.[0].credentialId), + ); + } + + const controller = new AbortController(); + void this.fido2AuthenticatorService + .getAssertion(this.convertAssertionRequest(request), null, controller) + .then((response) => { + callback(null, this.convertAssertionResponse(request, response)); + }) + .catch((error) => { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + }); + }); + } + + private convertRegistrationRequest( + request: autofill.PasskeyRegistrationRequest, + ): Fido2AuthenticatorMakeCredentialsParams { + return { + hash: new Uint8Array(request.clientDataHash), + rpEntity: { + name: request.rpId, + id: request.rpId, + }, + userEntity: { + id: new Uint8Array(request.userHandle), + name: request.userName, + displayName: undefined, + icon: undefined, + }, + credTypesAndPubKeyAlgs: request.supportedAlgorithms.map((alg) => ({ + alg, + type: "public-key", + })), + excludeCredentialDescriptorList: [], + requireResidentKey: true, + requireUserVerification: + request.userVerification === "required" || request.userVerification === "preferred", + fallbackSupported: false, + }; + } + + private convertRegistrationResponse( + request: autofill.PasskeyRegistrationRequest, + response: Fido2AuthenticatorMakeCredentialResult, + ): autofill.PasskeyRegistrationResponse { + return { + rpId: request.rpId, + clientDataHash: request.clientDataHash, + credentialId: Array.from(Fido2Utils.bufferSourceToUint8Array(response.credentialId)), + attestationObject: Array.from( + Fido2Utils.bufferSourceToUint8Array(response.attestationObject), + ), + }; + } + + private convertAssertionRequest( + request: autofill.PasskeyAssertionRequest, + ): Fido2AuthenticatorGetAssertionParams { + return { + rpId: request.rpId, + hash: new Uint8Array(request.clientDataHash), + allowCredentialDescriptorList: [ + { + id: new Uint8Array(request.credentialId), + type: "public-key", + }, + ], + extensions: {}, + requireUserVerification: + request.userVerification === "required" || request.userVerification === "preferred", + fallbackSupported: false, + }; + } + + private convertAssertionResponse( + request: autofill.PasskeyAssertionRequest, + response: Fido2AuthenticatorGetAssertionResult, + ): autofill.PasskeyAssertionResponse { + return { + userHandle: Array.from(response.selectedCredential.userHandle), + rpId: request.rpId, + signature: Array.from(response.signature), + clientDataHash: request.clientDataHash, + authenticatorData: Array.from(response.authenticatorData), + credentialId: Array.from(response.selectedCredential.id), + }; + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts new file mode 100644 index 00000000000..684b19e7394 --- /dev/null +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -0,0 +1,123 @@ +import { firstValueFrom, map } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { + Fido2UserInterfaceService as Fido2UserInterfaceServiceAbstraction, + Fido2UserInterfaceSession, + NewCredentialParams, + PickCredentialParams, +} from "@bitwarden/common/platform/abstractions/fido2/fido2-user-interface.service.abstraction"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; +import { CardView } from "@bitwarden/common/vault/models/view/card.view"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; +import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; +import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; + +export class DesktopFido2UserInterfaceService + implements Fido2UserInterfaceServiceAbstraction +{ + constructor( + private authService: AuthService, + private cipherService: CipherService, + private accountService: AccountService, + private logService: LogService, + ) {} + + async newSession( + fallbackSupported: boolean, + _tab: void, + abortController?: AbortController, + ): Promise { + this.logService.warning("newSession", fallbackSupported, abortController); + return new DesktopFido2UserInterfaceSession( + this.authService, + this.cipherService, + this.accountService, + this.logService, + ); + } +} + +export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSession { + constructor( + private authService: AuthService, + private cipherService: CipherService, + private accountService: AccountService, + private logService: LogService, + ) {} + + async pickCredential({ + cipherIds, + userVerification, + }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { + this.logService.warning("pickCredential", cipherIds, userVerification); + + return { cipherId: cipherIds[0], userVerified: userVerification }; + } + + async confirmNewCredential({ + credentialName, + userName, + userVerification, + rpId, + }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { + this.logService.warning( + "confirmNewCredential", + credentialName, + userName, + userVerification, + rpId, + ); + + // Store the passkey on a new cipher to avoid replacing something important + const cipher = new CipherView(); + cipher.name = credentialName; + + cipher.type = CipherType.Login; + cipher.login = new LoginView(); + cipher.login.username = userName; + cipher.login.uris = [new LoginUriView()]; + cipher.login.uris[0].uri = "https://" + rpId; + cipher.card = new CardView(); + cipher.identity = new IdentityView(); + cipher.secureNote = new SecureNoteView(); + cipher.secureNote.type = SecureNoteType.Generic; + cipher.reprompt = CipherRepromptType.None; + + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const encCipher = await this.cipherService.encrypt(cipher, activeUserId); + const createdCipher = await this.cipherService.createWithServer(encCipher); + + return { cipherId: createdCipher.id, userVerified: userVerification }; + } + + async informExcludedCredential(existingCipherIds: string[]): Promise { + this.logService.warning("informExcludedCredential", existingCipherIds); + } + + async ensureUnlockedVault(): Promise { + this.logService.warning("ensureUnlockedVault"); + + const status = await firstValueFrom(this.authService.activeAccountStatus$); + if (status !== AuthenticationStatus.Unlocked) { + throw new Error("Vault is not unlocked"); + } + } + + async informCredentialNotFound(): Promise { + this.logService.warning("informCredentialNotFound"); + } + + async close() { + this.logService.warning("close"); + } +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f8f81a5ac2c..323d0cd3f7b 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3394,5 +3394,41 @@ }, "fileSavedToDevice": { "message": "File saved to device. Manage from your device downloads." + }, + "importantNotice": { + "message": "Important notice" + }, + "setupTwoStepLogin": { + "message": "Set up two-step login" + }, + "newDeviceVerificationNoticeContentPage1": { + "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + }, + "newDeviceVerificationNoticeContentPage2": { + "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + }, + "remindMeLater": { + "message": "Remind me later" + }, + "newDeviceVerificationNoticePageOneFormContent": { + "message": "Do you have reliable access to your email, $EMAIL$?", + "placeholders": { + "email": { + "content": "$1", + "example": "your_name@email.com" + } + } + }, + "newDeviceVerificationNoticePageOneEmailAccessNo": { + "message": "No, I do not" + }, + "newDeviceVerificationNoticePageOneEmailAccessYes": { + "message": "Yes, I can reliably access my email" + }, + "turnOnTwoStepLogin": { + "message": "Turn on two-step login" + }, + "changeAcctEmail": { + "message": "Change account email" } } diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b19f25256b2..a4842249c93 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -261,7 +261,7 @@ export class Main { new EphemeralValueStorageService(); new SSOLocalhostCallbackService(this.environmentService, this.messagingService); - this.nativeAutofillMain = new NativeAutofillMain(this.logService); + this.nativeAutofillMain = new NativeAutofillMain(this.logService, this.windowMain); void this.nativeAutofillMain.init(); } diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index b4b7895e8ac..1465831340f 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -3,6 +3,8 @@ import { ipcMain } from "electron"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { autofill } from "@bitwarden/desktop-napi"; +import { WindowMain } from "../../../main/window.main"; + import { CommandDefinition } from "./command"; export type RunCommandParams = { @@ -14,7 +16,12 @@ export type RunCommandParams = { export type RunCommandResult = C["output"]; export class NativeAutofillMain { - constructor(private logService: LogService) {} + private ipcServer: autofill.IpcServer | null; + + constructor( + private logService: LogService, + private windowMain: WindowMain, + ) {} async init() { ipcMain.handle( @@ -26,6 +33,52 @@ export class NativeAutofillMain { return this.runCommand(params); }, ); + + this.ipcServer = await autofill.IpcServer.listen( + "autofill", + // RegistrationCallback + (error, clientId, sequenceNumber, request) => { + if (error) { + this.logService.error("autofill.IpcServer.registration", error); + return; + } + this.windowMain.win.webContents.send("autofill.passkeyRegistration", { + clientId, + sequenceNumber, + request, + }); + }, + // AssertionCallback + (error, clientId, sequenceNumber, request) => { + if (error) { + this.logService.error("autofill.IpcServer.assertion", error); + return; + } + this.windowMain.win.webContents.send("autofill.passkeyAssertion", { + clientId, + sequenceNumber, + request, + }); + }, + ); + + ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { + this.logService.warning("autofill.completePasskeyRegistration", data); + const { clientId, sequenceNumber, response } = data; + this.ipcServer.completeRegistration(clientId, sequenceNumber, response); + }); + + ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { + this.logService.warning("autofill.completePasskeyAssertion", data); + const { clientId, sequenceNumber, response } = data; + this.ipcServer.completeAssertion(clientId, sequenceNumber, response); + }); + + ipcMain.on("autofill.completeError", (event, data) => { + this.logService.warning("autofill.completeError", data); + const { clientId, sequenceNumber, error } = data; + this.ipcServer.completeAssertion(clientId, sequenceNumber, error); + }); } private async runCommand( diff --git a/apps/desktop/tailwind.config.js b/apps/desktop/tailwind.config.js index db1dd55694e..a561b93b21a 100644 --- a/apps/desktop/tailwind.config.js +++ b/apps/desktop/tailwind.config.js @@ -6,6 +6,7 @@ config.content = [ "../../libs/components/src/**/*.{html,ts}", "../../libs/auth/src/**/*.{html,ts}", "../../libs/angular/src/**/*.{html,ts}", + "../../libs/vault/src/**/*.{html,ts,mdx}", ]; module.exports = config; diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts index 6a72360cfad..7506f6c5d0b 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-access-view.component.ts @@ -4,16 +4,22 @@ import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import { ActivatedRoute, Router } from "@angular/router"; import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { CipherFormConfigService, DefaultCipherFormConfigService } from "@bitwarden/vault"; import { EmergencyAccessService } from "../../../emergency-access"; import { EmergencyAccessAttachmentsComponent } from "../attachments/emergency-access-attachments.component"; import { EmergencyAddEditCipherComponent } from "./emergency-add-edit-cipher.component"; +import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component"; @Component({ selector: "emergency-access-view", templateUrl: "emergency-access-view.component.html", + providers: [{ provide: CipherFormConfigService, useClass: DefaultCipherFormConfigService }], }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class EmergencyAccessViewComponent implements OnInit { @@ -31,6 +37,8 @@ export class EmergencyAccessViewComponent implements OnInit { private router: Router, private route: ActivatedRoute, private emergencyAccessService: EmergencyAccessService, + private configService: ConfigService, + private dialogService: DialogService, ) {} ngOnInit() { @@ -49,6 +57,19 @@ export class EmergencyAccessViewComponent implements OnInit { } async selectCipher(cipher: CipherView) { + const browserRefreshEnabled = await this.configService.getFeatureFlag( + FeatureFlag.ExtensionRefresh, + ); + + if (browserRefreshEnabled) { + EmergencyViewDialogComponent.open(this.dialogService, { + cipher, + }); + return; + } + + // FIXME PM-15385: Remove below dialog service logic once extension refresh is live. + // eslint-disable-next-line const [_, childComponent] = await this.modalService.openViewRef( EmergencyAddEditCipherComponent, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts index 2da8e06449a..59228431e65 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-add-edit-cipher.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { DatePipe } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, OnInit } from "@angular/core"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; @@ -30,7 +30,7 @@ import { AddEditComponent as BaseAddEditComponent } from "../../../../vault/indi selector: "app-org-vault-add-edit", templateUrl: "../../../../vault/individual-vault/add-edit.component.html", }) -export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { +export class EmergencyAddEditCipherComponent extends BaseAddEditComponent implements OnInit { originalCipher: Cipher = null; viewOnly = true; protected override componentName = "app-org-vault-add-edit"; @@ -85,6 +85,14 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent { this.title = this.i18nService.t("viewItem"); } + async ngOnInit(): Promise { + await super.ngOnInit(); + // The base component `ngOnInit` calculates the `viewOnly` property based on cipher properties + // In the case of emergency access, `viewOnly` should always be true, set it manually here after + // the base `ngOnInit` is complete. + this.viewOnly = true; + } + protected async loadCipher() { return Promise.resolve(this.originalCipher); } diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html new file mode 100644 index 00000000000..be38e1d9505 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.html @@ -0,0 +1,13 @@ + + + {{ title }} + +
+ +
+ + + +
diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts new file mode 100644 index 00000000000..341e44f643b --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.spec.ts @@ -0,0 +1,108 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; +import { mock } from "jest-mock-extended"; + +import { CollectionService } from "@bitwarden/admin-console/common"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; + +import { EmergencyViewDialogComponent } from "./emergency-view-dialog.component"; + +describe("EmergencyViewDialogComponent", () => { + let component: EmergencyViewDialogComponent; + let fixture: ComponentFixture; + + const open = jest.fn(); + const close = jest.fn(); + + const mockCipher = { + id: "cipher1", + name: "Cipher", + type: CipherType.Login, + login: { uris: [] }, + card: {}, + } as CipherView; + + beforeEach(async () => { + open.mockClear(); + close.mockClear(); + + await TestBed.configureTestingModule({ + imports: [EmergencyViewDialogComponent, NoopAnimationsModule], + providers: [ + { provide: OrganizationService, useValue: mock() }, + { provide: CollectionService, useValue: mock() }, + { provide: FolderService, useValue: mock() }, + { provide: I18nService, useValue: { t: (...keys: string[]) => keys.join(" ") } }, + { provide: DialogService, useValue: { open } }, + { provide: DialogRef, useValue: { close } }, + { provide: DIALOG_DATA, useValue: { cipher: mockCipher } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EmergencyViewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("creates", () => { + expect(component).toBeTruthy(); + }); + + it("opens dialog", () => { + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + + expect(open).toHaveBeenCalled(); + }); + + it("closes the dialog", () => { + EmergencyViewDialogComponent.open({ open } as unknown as DialogService, { cipher: mockCipher }); + fixture.detectChanges(); + + const cancelButton = fixture.debugElement.queryAll(By.css("button")).pop(); + + cancelButton.nativeElement.click(); + + expect(close).toHaveBeenCalled(); + }); + + describe("updateTitle", () => { + it("sets login title", () => { + mockCipher.type = CipherType.Login; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typelogin"); + }); + + it("sets card title", () => { + mockCipher.type = CipherType.Card; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typecard"); + }); + + it("sets identity title", () => { + mockCipher.type = CipherType.Identity; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType typeidentity"); + }); + + it("sets note title", () => { + mockCipher.type = CipherType.SecureNote; + + component["updateTitle"](); + + expect(component["title"]).toBe("viewItemType note"); + }); + }); +}); diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts new file mode 100644 index 00000000000..68423c50d88 --- /dev/null +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -0,0 +1,90 @@ +import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component, Inject } from "@angular/core"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { CipherViewComponent } from "@bitwarden/vault"; + +import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service"; + +export interface EmergencyViewDialogParams { + /** The cipher being viewed. */ + cipher: CipherView; +} + +/** Stubbed class, premium upgrade is not applicable for emergency viewing */ +class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { + async promptForPremium() { + return Promise.resolve(); + } +} + +@Component({ + selector: "app-emergency-view-dialog", + templateUrl: "emergency-view-dialog.component.html", + standalone: true, + imports: [ButtonModule, CipherViewComponent, DialogModule, CommonModule, JslibModule], + providers: [ + { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop }, + ], +}) +export class EmergencyViewDialogComponent { + /** + * The title of the dialog. Updates based on the cipher type. + * @protected + */ + protected title: string = ""; + + constructor( + @Inject(DIALOG_DATA) protected params: EmergencyViewDialogParams, + private dialogRef: DialogRef, + private i18nService: I18nService, + ) { + this.updateTitle(); + } + + get cipher(): CipherView { + return this.params.cipher; + } + + cancel = () => { + this.dialogRef.close(); + }; + + private updateTitle() { + const partOne = "viewItemType"; + + const type = this.cipher.type; + + switch (type) { + case CipherType.Login: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeLogin").toLowerCase()); + break; + case CipherType.Card: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeCard").toLowerCase()); + break; + case CipherType.Identity: + this.title = this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLowerCase()); + break; + case CipherType.SecureNote: + this.title = this.i18nService.t(partOne, this.i18nService.t("note").toLowerCase()); + break; + } + } + + /** + * Opens the EmergencyViewDialog. + */ + static open(dialogService: DialogService, params: EmergencyViewDialogParams) { + return dialogService.open(EmergencyViewDialogComponent, { + data: params, + }); + } +} diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 649f1aba534..9f2a86c1c06 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -13,6 +13,7 @@ import { import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard"; import { generatorSwap } from "@bitwarden/angular/tools/generator/generator-swap"; import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap"; +import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, @@ -40,6 +41,11 @@ import { LoginDecryptionOptionsComponent, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { + NewDeviceVerificationNoticePageOneComponent, + NewDeviceVerificationNoticePageTwoComponent, + VaultIcons, +} from "@bitwarden/vault"; import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap"; import { flagEnabled, Flags } from "../utils/flags"; @@ -695,10 +701,37 @@ const routes: Routes = [ }, ], }, + { + path: "new-device-notice", + component: AnonLayoutWrapperComponent, + canActivate: [], + children: [ + { + path: "", + component: NewDeviceVerificationNoticePageOneComponent, + data: { + pageIcon: VaultIcons.ExclamationTriangle, + pageTitle: { + key: "importantNotice", + }, + }, + }, + { + path: "setup", + component: NewDeviceVerificationNoticePageTwoComponent, + data: { + pageIcon: VaultIcons.UserLock, + pageTitle: { + key: "setupTwoStepLogin", + }, + }, + }, + ], + }, { path: "", component: UserLayoutComponent, - canActivate: [deepLinkGuard(), authGuard], + canActivate: [deepLinkGuard(), authGuard, NewDeviceVerificationNoticeGuard], children: [ { path: "vault", diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/apps/web/src/app/vault/individual-vault/password-history.component.html index 7127e7ca649..4eaca8f736e 100644 --- a/apps/web/src/app/vault/individual-vault/password-history.component.html +++ b/apps/web/src/app/vault/individual-vault/password-history.component.html @@ -3,7 +3,7 @@ {{ "passwordHistory" | i18n }} - + + diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts new file mode 100644 index 00000000000..c6eccb78739 --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.spec.ts @@ -0,0 +1,173 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { NewDeviceVerificationNoticeService } from "../../services/new-device-verification-notice.service"; + +import { NewDeviceVerificationNoticePageOneComponent } from "./new-device-verification-notice-page-one.component"; + +describe("NewDeviceVerificationNoticePageOneComponent", () => { + let component: NewDeviceVerificationNoticePageOneComponent; + let fixture: ComponentFixture; + + const activeAccount$ = new BehaviorSubject({ email: "test@example.com", id: "acct-1" }); + const navigate = jest.fn().mockResolvedValue(null); + const updateNewDeviceVerificationNoticeState = jest.fn().mockResolvedValue(null); + const getFeatureFlag = jest.fn().mockResolvedValue(null); + + beforeEach(async () => { + navigate.mockClear(); + updateNewDeviceVerificationNoticeState.mockClear(); + getFeatureFlag.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: { t: (...key: string[]) => key.join(" ") } }, + { provide: Router, useValue: { navigate } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { + provide: NewDeviceVerificationNoticeService, + useValue: { updateNewDeviceVerificationNoticeState }, + }, + { provide: PlatformUtilsService, useValue: { getClientType: () => false } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewDeviceVerificationNoticePageOneComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("sets initial properties", () => { + expect(component["currentEmail"]).toBe("test@example.com"); + expect(component["currentUserId"]).toBe("acct-1"); + }); + + describe("temporary flag submission", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationTemporaryDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + describe("no email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(0); + fixture.detectChanges(); + + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + it("redirects to step two ", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["new-device-notice/setup"]); + }); + + it("does not update notice state", () => { + expect(getFeatureFlag).not.toHaveBeenCalled(); + expect(updateNewDeviceVerificationNoticeState).not.toHaveBeenCalled(); + }); + }); + + describe("has email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(1); + fixture.detectChanges(); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-03-03T00:00:00.000Z")); + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("redirects to the vault", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state with a new date", () => { + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-03-03T00:00:00.000Z"), + permanent_dismissal: false, + }); + }); + }); + }); + + describe("permanent flag submission", () => { + beforeEach(() => { + getFeatureFlag.mockImplementation((key) => { + if (key === FeatureFlag.NewDeviceVerificationPermanentDismiss) { + return Promise.resolve(true); + } + + return Promise.resolve(false); + }); + }); + + describe("no email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(0); + fixture.detectChanges(); + + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + it("redirects to step two", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["new-device-notice/setup"]); + }); + + it("does not update notice state", () => { + expect(getFeatureFlag).not.toHaveBeenCalled(); + expect(updateNewDeviceVerificationNoticeState).not.toHaveBeenCalled(); + }); + }); + + describe("has email access", () => { + beforeEach(() => { + component["formGroup"].controls.hasEmailAccess.setValue(1); + fixture.detectChanges(); + + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-04-04T00:00:00.000Z")); + const submit = fixture.debugElement.query(By.css('button[type="submit"]')); + submit.nativeElement.click(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("redirects to the vault ", () => { + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state with a new date", () => { + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-04-04T00:00:00.000Z"), + permanent_dismissal: true, + }); + }); + }); + }); +}); diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts new file mode 100644 index 00000000000..70ac4073a0d --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-one.component.ts @@ -0,0 +1,114 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms"; +import { Router } from "@angular/router"; +import { firstValueFrom, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AsyncActionsModule, + ButtonModule, + CardComponent, + FormFieldModule, + RadioButtonModule, + TypographyModule, +} from "@bitwarden/components"; + +import { + NewDeviceVerificationNotice, + NewDeviceVerificationNoticeService, +} from "./../../services/new-device-verification-notice.service"; + +@Component({ + standalone: true, + selector: "app-new-device-verification-notice-page-one", + templateUrl: "./new-device-verification-notice-page-one.component.html", + imports: [ + CardComponent, + CommonModule, + JslibModule, + TypographyModule, + ButtonModule, + RadioButtonModule, + FormFieldModule, + AsyncActionsModule, + ReactiveFormsModule, + ], +}) +export class NewDeviceVerificationNoticePageOneComponent implements OnInit { + protected formGroup = this.formBuilder.group({ + hasEmailAccess: new FormControl(0), + }); + protected isDesktop: boolean; + readonly currentAcct$: Observable = this.accountService.activeAccount$; + protected currentEmail: string = ""; + private currentUserId: UserId | null = null; + + constructor( + private formBuilder: FormBuilder, + private router: Router, + private accountService: AccountService, + private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService, + private platformUtilsService: PlatformUtilsService, + private configService: ConfigService, + ) { + this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop; + } + + async ngOnInit() { + const currentAcct = await firstValueFrom(this.currentAcct$); + if (!currentAcct) { + return; + } + this.currentEmail = currentAcct.email; + this.currentUserId = currentAcct.id; + } + + submit = async () => { + const doesNotHaveEmailAccess = this.formGroup.controls.hasEmailAccess.value === 0; + + if (doesNotHaveEmailAccess) { + await this.router.navigate(["new-device-notice/setup"]); + return; + } + + const tempNoticeFlag = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationTemporaryDismiss, + ); + const permNoticeFlag = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationPermanentDismiss, + ); + + let newNoticeState: NewDeviceVerificationNotice | null = null; + + // When the temporary flag is enabled, only update the `last_dismissal` + if (tempNoticeFlag) { + newNoticeState = { + last_dismissal: new Date(), + permanent_dismissal: false, + }; + } else if (permNoticeFlag) { + // When the per flag is enabled, only update the `last_dismissal` + newNoticeState = { + last_dismissal: new Date(), + permanent_dismissal: true, + }; + } + + // This shouldn't occur as the user shouldn't get here unless one of the flags is active. + if (newNoticeState) { + await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState( + this.currentUserId!, + newNoticeState, + ); + } + + await this.router.navigate(["/vault"]); + }; +} diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html new file mode 100644 index 00000000000..66a61f3b8df --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.html @@ -0,0 +1,41 @@ +

+ {{ "newDeviceVerificationNoticeContentPage2" | i18n }} +

+ + + {{ "turnOnTwoStepLogin" | i18n }} + + + + {{ "changeAcctEmail" | i18n }} + + + + diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts new file mode 100644 index 00000000000..92f0494776a --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.spec.ts @@ -0,0 +1,175 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { By } from "@angular/platform-browser"; +import { Router } from "@angular/router"; +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { NewDeviceVerificationNoticeService } from "../../services/new-device-verification-notice.service"; + +import { NewDeviceVerificationNoticePageTwoComponent } from "./new-device-verification-notice-page-two.component"; + +describe("NewDeviceVerificationNoticePageTwoComponent", () => { + let component: NewDeviceVerificationNoticePageTwoComponent; + let fixture: ComponentFixture; + + const activeAccount$ = new BehaviorSubject({ email: "test@example.com", id: "acct-1" }); + const environment$ = new BehaviorSubject({ getWebVaultUrl: () => "vault.bitwarden.com" }); + const navigate = jest.fn().mockResolvedValue(null); + const updateNewDeviceVerificationNoticeState = jest.fn().mockResolvedValue(null); + const getFeatureFlag = jest.fn().mockResolvedValue(false); + const getClientType = jest.fn().mockReturnValue(ClientType.Browser); + const launchUri = jest.fn(); + + beforeEach(async () => { + navigate.mockClear(); + updateNewDeviceVerificationNoticeState.mockClear(); + getFeatureFlag.mockClear(); + getClientType.mockClear(); + launchUri.mockClear(); + + await TestBed.configureTestingModule({ + providers: [ + { provide: I18nService, useValue: { t: (...key: string[]) => key.join(" ") } }, + { provide: Router, useValue: { navigate } }, + { provide: AccountService, useValue: { activeAccount$ } }, + { provide: EnvironmentService, useValue: { environment$ } }, + { + provide: NewDeviceVerificationNoticeService, + useValue: { updateNewDeviceVerificationNoticeState }, + }, + { provide: PlatformUtilsService, useValue: { getClientType, launchUri } }, + { provide: ConfigService, useValue: { getFeatureFlag } }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(NewDeviceVerificationNoticePageTwoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("sets initial properties", () => { + expect(component["currentUserId"]).toBe("acct-1"); + expect(component["permanentFlagEnabled"]).toBe(false); + }); + + describe("change email", () => { + const changeEmailButton = () => + fixture.debugElement.query(By.css('[data-testid="change-email"]')); + + describe("web", () => { + beforeEach(() => { + component["isWeb"] = true; + fixture.detectChanges(); + }); + + it("navigates to settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/settings/account"], { + queryParams: { fromNewDeviceVerification: true }, + }); + expect(launchUri).not.toHaveBeenCalled(); + }); + }); + + describe("browser/desktop", () => { + beforeEach(() => { + component["isWeb"] = false; + fixture.detectChanges(); + }); + + it("launches to settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).not.toHaveBeenCalled(); + expect(launchUri).toHaveBeenCalledWith( + "vault.bitwarden.com/#/settings/account/?fromNewDeviceVerification=true", + ); + }); + }); + }); + + describe("enable 2fa", () => { + const changeEmailButton = () => + fixture.debugElement.query(By.css('[data-testid="two-factor"]')); + + describe("web", () => { + beforeEach(() => { + component["isWeb"] = true; + fixture.detectChanges(); + }); + + it("navigates to two factor settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/settings/security/two-factor"], { + queryParams: { fromNewDeviceVerification: true }, + }); + expect(launchUri).not.toHaveBeenCalled(); + }); + }); + + describe("browser/desktop", () => { + beforeEach(() => { + component["isWeb"] = false; + fixture.detectChanges(); + }); + + it("launches to two factor settings", () => { + changeEmailButton().nativeElement.click(); + + expect(navigate).not.toHaveBeenCalled(); + expect(launchUri).toHaveBeenCalledWith( + "vault.bitwarden.com/#/settings/security/two-factor/?fromNewDeviceVerification=true", + ); + }); + }); + }); + + describe("remind me later", () => { + const remindMeLater = () => + fixture.debugElement.query(By.css('[data-testid="remind-me-later"]')); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-02-02T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("navigates to the vault", () => { + remindMeLater().nativeElement.click(); + + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(["/vault"]); + }); + + it("updates notice state", () => { + remindMeLater().nativeElement.click(); + + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledTimes(1); + expect(updateNewDeviceVerificationNoticeState).toHaveBeenCalledWith("acct-1", { + last_dismissal: new Date("2024-02-02T00:00:00.000Z"), + permanent_dismissal: false, + }); + }); + + it("is hidden when the permanent flag is enabled", async () => { + getFeatureFlag.mockResolvedValueOnce(true); + await component.ngOnInit(); + fixture.detectChanges(); + + expect(remindMeLater()).toBeNull(); + }); + }); +}); diff --git a/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts new file mode 100644 index 00000000000..b3634dcc28f --- /dev/null +++ b/libs/vault/src/components/new-device-verification-notice/new-device-verification-notice-page-two.component.ts @@ -0,0 +1,103 @@ +import { CommonModule } from "@angular/common"; +import { Component, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom, Observable } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ClientType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { + Environment, + EnvironmentService, +} from "@bitwarden/common/platform/abstractions/environment.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ButtonModule, LinkModule, TypographyModule } from "@bitwarden/components"; + +import { NewDeviceVerificationNoticeService } from "../../services/new-device-verification-notice.service"; + +@Component({ + standalone: true, + selector: "app-new-device-verification-notice-page-two", + templateUrl: "./new-device-verification-notice-page-two.component.html", + imports: [CommonModule, JslibModule, TypographyModule, ButtonModule, LinkModule], +}) +export class NewDeviceVerificationNoticePageTwoComponent implements OnInit { + protected isWeb: boolean; + protected isDesktop: boolean; + protected permanentFlagEnabled = false; + readonly currentAcct$: Observable = this.accountService.activeAccount$; + private currentUserId: UserId | null = null; + private env$: Observable = this.environmentService.environment$; + + constructor( + private newDeviceVerificationNoticeService: NewDeviceVerificationNoticeService, + private router: Router, + private accountService: AccountService, + private platformUtilsService: PlatformUtilsService, + private environmentService: EnvironmentService, + private configService: ConfigService, + ) { + this.isWeb = this.platformUtilsService.getClientType() === ClientType.Web; + this.isDesktop = this.platformUtilsService.getClientType() === ClientType.Desktop; + } + + async ngOnInit() { + this.permanentFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.NewDeviceVerificationPermanentDismiss, + ); + + const currentAcct = await firstValueFrom(this.currentAcct$); + if (!currentAcct) { + return; + } + this.currentUserId = currentAcct.id; + } + + async navigateToTwoStepLogin(event: Event) { + event.preventDefault(); + + const env = await firstValueFrom(this.env$); + const url = env.getWebVaultUrl(); + + if (this.isWeb) { + await this.router.navigate(["/settings/security/two-factor"], { + queryParams: { fromNewDeviceVerification: true }, + }); + } else { + this.platformUtilsService.launchUri( + url + "/#/settings/security/two-factor/?fromNewDeviceVerification=true", + ); + } + } + + async navigateToChangeAcctEmail(event: Event) { + event.preventDefault(); + + const env = await firstValueFrom(this.env$); + const url = env.getWebVaultUrl(); + if (this.isWeb) { + await this.router.navigate(["/settings/account"], { + queryParams: { fromNewDeviceVerification: true }, + }); + } else { + this.platformUtilsService.launchUri( + url + "/#/settings/account/?fromNewDeviceVerification=true", + ); + } + } + + async remindMeLaterSelect() { + await this.newDeviceVerificationNoticeService.updateNewDeviceVerificationNoticeState( + this.currentUserId!, + { + last_dismissal: new Date(), + permanent_dismissal: false, + }, + ); + + await this.router.navigate(["/vault"]); + } +} diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts index 3900681f230..461cc734d76 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.spec.ts @@ -46,6 +46,7 @@ describe("PasswordHistoryViewComponent", () => { fixture = TestBed.createComponent(PasswordHistoryViewComponent); component = fixture.componentInstance; + component.cipher = mockCipher; fixture.detectChanges(); }); @@ -60,8 +61,8 @@ describe("PasswordHistoryViewComponent", () => { beforeEach(async () => { mockCipher.passwordHistory = [password1, password2]; - mockCipherService.get.mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }); - await component.ngOnInit(); + component.cipher = mockCipher; + component.ngOnInit(); fixture.detectChanges(); }); diff --git a/libs/vault/src/components/password-history-view/password-history-view.component.ts b/libs/vault/src/components/password-history-view/password-history-view.component.ts index b038d89c464..0f3c54d9d2b 100644 --- a/libs/vault/src/components/password-history-view/password-history-view.component.ts +++ b/libs/vault/src/components/password-history-view/password-history-view.component.ts @@ -2,13 +2,9 @@ // @ts-strict-ignore import { CommonModule } from "@angular/common"; import { OnInit, Component, Input } from "@angular/core"; -import { firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { CipherId, UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view"; import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/components"; @@ -20,39 +16,14 @@ import { ItemModule, ColorPasswordModule, IconButtonModule } from "@bitwarden/co }) export class PasswordHistoryViewComponent implements OnInit { /** - * The ID of the cipher to display the password history for. + * Optional cipher view. When included `cipherId` is ignored. */ - @Input({ required: true }) cipherId: CipherId; + @Input({ required: true }) cipher: CipherView; /** The password history for the cipher. */ history: PasswordHistoryView[] = []; - constructor( - protected cipherService: CipherService, - protected i18nService: I18nService, - protected accountService: AccountService, - ) {} - - async ngOnInit() { - await this.init(); - } - - /** Retrieve the password history for the given cipher */ - protected async init() { - const cipher = await this.cipherService.get(this.cipherId); - const activeAccount = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a: { id: string | undefined }) => a)), - ); - - if (!activeAccount?.id) { - throw new Error("Active account is not available."); - } - - const activeUserId = activeAccount.id as UserId; - const decCipher = await cipher.decrypt( - await this.cipherService.getKeyForCipherKeyDecryption(cipher, activeUserId), - ); - - this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; + ngOnInit() { + this.history = this.cipher.passwordHistory == null ? [] : this.cipher.passwordHistory; } } diff --git a/libs/vault/src/icons/exclamation-triangle.ts b/libs/vault/src/icons/exclamation-triangle.ts new file mode 100644 index 00000000000..6340546d1e1 --- /dev/null +++ b/libs/vault/src/icons/exclamation-triangle.ts @@ -0,0 +1,7 @@ +import { svgIcon } from "@bitwarden/components"; + +export const ExclamationTriangle = svgIcon` + + + +`; diff --git a/libs/vault/src/icons/index.ts b/libs/vault/src/icons/index.ts index c1b69a31ef5..2e106782f53 100644 --- a/libs/vault/src/icons/index.ts +++ b/libs/vault/src/icons/index.ts @@ -2,3 +2,5 @@ export * from "./deactivated-org"; export * from "./no-folders"; export * from "./vault"; export * from "./empty-trash"; +export * from "./exclamation-triangle"; +export * from "./user-lock"; diff --git a/libs/vault/src/icons/user-lock.ts b/libs/vault/src/icons/user-lock.ts new file mode 100644 index 00000000000..c1dc3efde39 --- /dev/null +++ b/libs/vault/src/icons/user-lock.ts @@ -0,0 +1,17 @@ +import { svgIcon } from "@bitwarden/components"; + +export const UserLock = svgIcon` + + + + + + + + + + + + + +`; diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index dca9b2dee79..0112de44241 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -14,5 +14,7 @@ export { export { DownloadAttachmentComponent } from "./components/download-attachment/download-attachment.component"; export { PasswordHistoryViewComponent } from "./components/password-history-view/password-history-view.component"; +export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component"; +export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component"; export * as VaultIcons from "./icons"; diff --git a/libs/vault/src/services/new-device-verification-notice.service.ts b/libs/vault/src/services/new-device-verification-notice.service.ts index 6c7df590b50..bb096ff0c2c 100644 --- a/libs/vault/src/services/new-device-verification-notice.service.ts +++ b/libs/vault/src/services/new-device-verification-notice.service.ts @@ -57,7 +57,7 @@ export class NewDeviceVerificationNoticeService { } async updateNewDeviceVerificationNoticeState( - userId: UserId, + userId: UserId | null, newState: NewDeviceVerificationNotice, ): Promise { await this.noticeState(userId).update(() => { diff --git a/package-lock.json b/package-lock.json index 0d743316bd4..a8ae6daf236 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,7 +68,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.66", + "tldts": "6.1.69", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2" @@ -221,7 +221,7 @@ "papaparse": "5.4.1", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "tldts": "6.1.66", + "tldts": "6.1.69", "zxcvbn": "4.4.2" }, "bin": { @@ -31135,21 +31135,21 @@ } }, "node_modules/tldts": { - "version": "6.1.66", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.66.tgz", - "integrity": "sha512-l3ciXsYFel/jSRfESbyKYud1nOw7WfhrBEF9I3UiarYk/qEaOOwu3qXNECHw4fHGHGTEOuhf/VdKgoDX5M/dhQ==", + "version": "6.1.69", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.69.tgz", + "integrity": "sha512-Oh/CqRQ1NXNY7cy9NkTPUauOWiTro0jEYZTioGbOmcQh6EC45oribyIMJp0OJO3677r13tO6SKdWoGZUx2BDFw==", "license": "MIT", "dependencies": { - "tldts-core": "^6.1.66" + "tldts-core": "^6.1.69" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.66", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.66.tgz", - "integrity": "sha512-s07jJruSwndD2X8bVjwioPfqpIc1pDTzszPe9pL1Skbh4bjytL85KNQ3tolqLbCvpQHawIsGfFi9dgerWjqW4g==", + "version": "6.1.69", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.69.tgz", + "integrity": "sha512-nygxy9n2PBUFQUtAXAc122gGo+04/j5qr5TGQFZTHafTKYvmARVXt2cA5rgero2/dnXUfkdPtiJoKmrd3T+wdA==", "license": "MIT" }, "node_modules/tmp": { diff --git a/package.json b/package.json index 10799b9e451..03991f9d70b 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "qrious": "4.0.2", "rxjs": "7.8.1", "tabbable": "6.2.0", - "tldts": "6.1.66", + "tldts": "6.1.69", "utf-8-validate": "6.0.5", "zone.js": "0.14.10", "zxcvbn": "4.4.2"