1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-19 09:43:23 +00:00

Merge branch 'PM-12985-Reports' of github.com:bitwarden/clients into PM-12985-Reports

This commit is contained in:
--global
2024-12-19 15:39:45 -05:00
83 changed files with 3668 additions and 249 deletions

View File

@@ -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"
},

View File

@@ -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<chrome.runtime.MessageSender>;
let logService!: MockProxy<LogService>;
let fido2ActiveRequestManager: MockProxy<Fido2ActiveRequestManager>;
let fido2ClientService!: MockProxy<Fido2ClientService>;
let fido2ClientService!: MockProxy<Fido2ClientService<BrowserFido2ParentWindowReference>>;
let vaultSettingsService!: MockProxy<VaultSettingsService>;
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
let configServiceMock!: MockProxy<ConfigService>;
@@ -73,7 +74,7 @@ describe("Fido2Background", () => {
});
senderMock = mock<chrome.runtime.MessageSender>({ id: "1", tab: tabMock });
logService = mock<LogService>();
fido2ClientService = mock<Fido2ClientService>();
fido2ClientService = mock<Fido2ClientService<BrowserFido2ParentWindowReference>>();
vaultSettingsService = mock<VaultSettingsService>();
abortManagerMock = mock<AbortManager>();
abortController = mock<AbortController>();

View File

@@ -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<BrowserFido2ParentWindowReference>,
private vaultSettingsService: VaultSettingsService,
private scriptInjectorService: ScriptInjectorService,
private configService: ConfigService,

View File

@@ -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<BrowserFido2ParentWindowReference>
{
constructor(private authService: AuthService) {}
async newSession(

View File

@@ -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<BrowserFido2ParentWindowReference>;
fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<BrowserFido2ParentWindowReference>;
fido2ActiveRequestManager: Fido2ActiveRequestManagerAbstraction;
fido2ClientService: Fido2ClientServiceAbstraction;
fido2ClientService: Fido2ClientServiceAbstraction<BrowserFido2ParentWindowReference>;
avatarService: AvatarServiceAbstraction;
mainContextMenuHandler: MainContextMenuHandler;
cipherContextMenuHandler: CipherContextMenuHandler;
@@ -1330,7 +1333,7 @@ export default class MainBackground {
return new Promise<void>((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

View File

@@ -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,
}),

View File

@@ -1,8 +1,8 @@
<popup-page>
<popup-page [loading]="!cipher">
<popup-header slot="header" pageTitle="{{ 'passwordHistory' | i18n }}" showBackButton>
<ng-container slot="end">
<app-pop-out></app-pop-out>
</ng-container>
</popup-header>
<vault-password-history-view *ngIf="cipherId" [cipherId]="cipherId" />
<vault-password-history-view *ngIf="cipher" [cipher]="cipher" />
</popup-page>

View File

@@ -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<PasswordHistoryV2Component>;
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<PlatformUtilsService>() },
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
{ provide: AccountService, useValue: mock<AccountService>() },
{ provide: CipherService, useValue: mock<CipherService>({ get: getCipher }) },
{
provide: AccountService,
useValue: mock<AccountService>({
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();
});
});

View File

@@ -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),
);
}
}

View File

@@ -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" },
});
});
});

View File

@@ -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 },
});
}
}

View File

@@ -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"
}
}

View File

@@ -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"

View File

@@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["napi", "core", "proxy"]
members = ["napi", "core", "proxy", "macos_provider"]

View File

@@ -0,0 +1 @@
BitwardenMacosProviderFFI.xcframework

View File

@@ -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"] }

View File

@@ -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

View File

@@ -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<u8>,
user_name: String,
user_handle: Vec<u8>,
record_identifier: Option<String>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
rp_id: String,
user_handle: Vec<u8>,
signature: Vec<u8>,
client_data_hash: Vec<u8>,
authenticator_data: Vec<u8>,
credential_id: Vec<u8>,
}
#[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<dyn PreparePasskeyAssertionCallback> {
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);
}
}

View File

@@ -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<String>,
// 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<Mutex<HashMap<u32, (Box<dyn Callback>, 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::<SerializedMessage>(&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<dyn PreparePasskeyRegistrationCallback>,
) {
self.send_message(request, Box::new(callback));
}
pub fn prepare_passkey_assertion(
&self,
request: PasskeyAssertionRequest,
callback: Arc<dyn PreparePasskeyAssertionCallback>,
) {
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<serde_json::Value, BitwardenError>,
},
}
impl MacOSProviderClient {
fn add_callback(&self, callback: Box<dyn Callback>) -> 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<dyn Callback>,
) {
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
)));
}
}
}
}

View File

@@ -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<u8>,
client_data_hash: Vec<u8>,
user_verification: UserVerification,
supported_algorithms: Vec<i32>,
}
#[derive(uniffi::Record, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
rp_id: String,
client_data_hash: Vec<u8>,
credential_id: Vec<u8>,
attestation_object: Vec<u8>,
}
#[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<dyn PreparePasskeyRegistrationCallback> {
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);
}
}

View File

@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

View File

@@ -0,0 +1,4 @@
[bindings.swift]
ffi_module_name = "BitwardenMacosProviderFFI"
module_name = "BitwardenMacosProvider"
generate_immutable_records = true

View File

@@ -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"

View File

@@ -124,6 +124,58 @@ export declare namespace ipc {
}
export declare namespace autofill {
export function runCommand(value: string): Promise<string>
export const enum UserVerification {
Preferred = 'preferred',
Required = 'required',
Discouraged = 'discouraged'
}
export interface PasskeyRegistrationRequest {
rpId: string
userName: string
userHandle: Array<number>
clientDataHash: Array<number>
userVerification: UserVerification
supportedAlgorithms: Array<number>
}
export interface PasskeyRegistrationResponse {
rpId: string
clientDataHash: Array<number>
credentialId: Array<number>
attestationObject: Array<number>
}
export interface PasskeyAssertionRequest {
rpId: string
credentialId: Array<number>
userName: string
userHandle: Array<number>
recordIdentifier?: string
clientDataHash: Array<number>
userVerification: UserVerification
}
export interface PasskeyAssertionResponse {
rpId: string
userHandle: Array<number>
signature: Array<number>
clientDataHash: Array<number>
authenticatorData: Array<number>
credentialId: Array<number>
}
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<IpcServer>
/** 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<Buffer>

View File

@@ -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<String> {
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<T: Serialize + DeserializeOwned> {
pub sequence_number: u32,
pub value: Result<T, BitwardenError>,
}
#[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<u8>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
pub supported_algorithms: Vec<i32>,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyRegistrationResponse {
pub rp_id: String,
pub client_data_hash: Vec<u8>,
pub credential_id: Vec<u8>,
pub attestation_object: Vec<u8>,
}
#[napi(object)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionRequest {
pub rp_id: String,
pub credential_id: Vec<u8>,
pub user_name: String,
pub user_handle: Vec<u8>,
pub record_identifier: Option<String>,
pub client_data_hash: Vec<u8>,
pub user_verification: UserVerification,
}
#[napi(object)]
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PasskeyAssertionResponse {
pub rp_id: String,
pub user_handle: Vec<u8>,
pub signature: Vec<u8>,
pub client_data_hash: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub credential_id: Vec<u8>,
}
#[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<Self> {
let (send, mut recv) = tokio::sync::mpsc::channel::<Message>(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::<PasskeyMessage<PasskeyAssertionRequest>>(
&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::<PasskeyMessage<PasskeyRegistrationRequest>>(
&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<u32> {
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<u32> {
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<u32> {
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<u32> {
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]

1
apps/desktop/macos/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
BitwardenMacosProvider.swift

View File

@@ -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)")
}
}

View File

@@ -6,5 +6,9 @@
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>LTZ2PFU5D6.com.bitwarden.desktop</string>
</array>
</dict>
</plist>

View File

@@ -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 = "<group>"; };
3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = "<group>"; };
968ED08A2C52A47200FFFEE6 /* Production.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Production.xcconfig; sourceTree = "<group>"; };
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 = "<group>";
@@ -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;

View File

@@ -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",

View File

@@ -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 },

View File

@@ -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,

View File

@@ -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: <C extends Command>(params: RunCommandParams<C>): Promise<RunCommandResult<C>> =>
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,
});
});
},
);
},
};

View File

@@ -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<void>,
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();

View File

@@ -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<void>
{
constructor(
private authService: AuthService,
private cipherService: CipherService,
private accountService: AccountService,
private logService: LogService,
) {}
async newSession(
fallbackSupported: boolean,
_tab: void,
abortController?: AbortController,
): Promise<DesktopFido2UserInterfaceSession> {
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<void> {
this.logService.warning("informExcludedCredential", existingCipherIds);
}
async ensureUnlockedVault(): Promise<void> {
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<void> {
this.logService.warning("informCredentialNotFound");
}
async close() {
this.logService.warning("close");
}
}

View File

@@ -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"
}
}

View File

@@ -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();
}

View File

@@ -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<C extends CommandDefinition> = {
@@ -14,7 +16,12 @@ export type RunCommandParams<C extends CommandDefinition> = {
export type RunCommandResult<C extends CommandDefinition> = 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<C extends CommandDefinition>(

View File

@@ -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;

View File

@@ -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,

View File

@@ -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<void> {
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);
}

View File

@@ -0,0 +1,13 @@
<bit-dialog dialogSize="large" background="alt" #dialog>
<span bitDialogTitle aria-live="polite">
{{ title }}
</span>
<div bitDialogContent #dialogContent>
<app-cipher-view [cipher]="cipher"></app-cipher-view>
</div>
<ng-container bitDialogFooter>
<button bitButton type="button" buttonType="secondary" (click)="cancel()">
{{ "cancel" | i18n }}
</button>
</ng-container>
</bit-dialog>

View File

@@ -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<EmergencyViewDialogComponent>;
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<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: FolderService, useValue: mock<FolderService>() },
{ 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");
});
});
});

View File

@@ -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<EmergencyViewDialogParams>(EmergencyViewDialogComponent, {
data: params,
});
}
}

View File

@@ -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",

View File

@@ -3,7 +3,7 @@
{{ "passwordHistory" | i18n }}
</span>
<ng-container bitDialogContent>
<vault-password-history-view [cipherId]="cipherId" />
<vault-password-history-view [cipher]="cipher" />
</ng-container>
<ng-container bitDialogFooter>
<button bitButton (click)="close()" buttonType="primary" type="button">

View File

@@ -4,8 +4,7 @@ import { DIALOG_DATA, DialogConfig, DialogRef } from "@angular/cdk/dialog";
import { CommonModule } from "@angular/common";
import { Inject, Component } from "@angular/core";
import { CipherId } from "@bitwarden/common/types/guid";
import { PasswordHistoryView } from "@bitwarden/common/vault/models/view/password-history.view";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { AsyncActionsModule, DialogModule, DialogService } from "@bitwarden/components";
import { PasswordHistoryViewComponent } from "@bitwarden/vault";
@@ -15,7 +14,7 @@ import { SharedModule } from "../../shared/shared.module";
* The parameters for the password history dialog.
*/
export interface ViewPasswordHistoryDialogParams {
cipherId: CipherId;
cipher: CipherView;
}
/**
@@ -35,14 +34,9 @@ export interface ViewPasswordHistoryDialogParams {
})
export class PasswordHistoryComponent {
/**
* The ID of the cipher to display the password history for.
* The cipher to display the password history for.
*/
cipherId: CipherId;
/**
* The password history for the cipher.
*/
history: PasswordHistoryView[] = [];
cipher: CipherView;
/**
* The constructor for the password history dialog component.
@@ -54,9 +48,9 @@ export class PasswordHistoryComponent {
private dialogRef: DialogRef<PasswordHistoryComponent>,
) {
/**
* Set the cipher ID from the parameters.
* Set the cipher from the parameters.
*/
this.cipherId = params.cipherId;
this.cipher = params.cipher;
}
/**

View File

@@ -1,7 +1,7 @@
import { Overlay } from "@angular/cdk/overlay";
import { TestBed } from "@angular/core/testing";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { openPasswordHistoryDialog } from "../individual-vault/password-history.component";
@@ -35,10 +35,10 @@ describe("WebViewPasswordHistoryService", () => {
describe("viewPasswordHistory", () => {
it("calls openPasswordHistoryDialog with the correct parameters", async () => {
const mockCipherId = "cipher-id" as CipherId;
await service.viewPasswordHistory(mockCipherId);
const mockCipher = { id: "cipher-id" } as CipherView;
await service.viewPasswordHistory(mockCipher);
expect(openPasswordHistoryDialog).toHaveBeenCalledWith(dialogService, {
data: { cipherId: mockCipherId },
data: { cipher: mockCipher },
});
});
});

View File

@@ -1,6 +1,6 @@
import { Injectable } from "@angular/core";
import { CipherId } from "@bitwarden/common/types/guid";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { DialogService } from "@bitwarden/components";
import { ViewPasswordHistoryService } from "../../../../../../libs/common/src/vault/abstractions/view-password-history.service";
@@ -17,7 +17,7 @@ export class WebViewPasswordHistoryService implements ViewPasswordHistoryService
* Opens the password history dialog for the given cipher ID.
* @param cipherId The ID of the cipher to view the password history for.
*/
async viewPasswordHistory(cipherId: CipherId) {
openPasswordHistoryDialog(this.dialogService, { data: { cipherId } });
async viewPasswordHistory(cipher: CipherView) {
openPasswordHistoryDialog(this.dialogService, { data: { cipher } });
}
}

View File

@@ -9888,6 +9888,42 @@
"descriptorCode": {
"message": "Descriptor code"
},
"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"
},
"removeMembers": {
"message": "Remove members"
},