1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 09:43:29 +00:00

Merge branch 'main' into neuronull/desktop-native-clean-unused-deps

This commit is contained in:
neuronull
2025-11-06 08:11:54 -07:00
150 changed files with 3035 additions and 1512 deletions

View File

@@ -187,7 +187,6 @@
"json5",
"keytar",
"libc",
"log",
"lowdb",
"mini-css-extract-plugin",
"napi",
@@ -216,6 +215,8 @@
"simplelog",
"style-loader",
"sysinfo",
"tracing",
"tracing-subscriber",
"ts-node",
"ts-loader",
"tsconfig-paths-webpack-plugin",

View File

@@ -4,6 +4,7 @@ import { componentWrapperDecorator } from "@storybook/angular";
import type { Preview } from "@storybook/angular";
import docJson from "../documentation.json";
setCompodocJson(docJson);
const wrapperDecorator = componentWrapperDecorator((story) => {

View File

@@ -12,6 +12,13 @@ export function mockPorts() {
(chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => {
const port = mockDeep<chrome.runtime.Port>();
port.name = portInfo.name;
port.sender = { url: chrome.runtime.getURL("") };
// convert to internal port
delete (port as any).tab;
delete (port as any).documentId;
delete (port as any).documentLifecycle;
delete (port as any).frameId;
// set message broadcast
(port.postMessage as jest.Mock).mockImplementation((message) => {

View File

@@ -4974,6 +4974,16 @@
}
}
},
"defaultLabelWithValue": {
"message": "Default ( $VALUE$ )",
"description": "A label that indicates the default value for a field with the current default value in parentheses.",
"placeholders": {
"value": {
"content": "$1",
"example": "Base domain"
}
}
},
"showMatchDetection": {
"message": "Show match detection $WEBSITE$",
"placeholders": {

View File

@@ -122,10 +122,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
async lock(userId: string) {
this.loading = true;
await this.vaultTimeoutService.lock(userId);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.router.navigate(["lock"]);
await this.lockService.lock(userId as UserId);
await this.router.navigate(["lock"]);
}
async lockAll() {

View File

@@ -25,7 +25,7 @@
<div class="tw-text-sm tw-italic" [attr.aria-hidden]="status.text === 'active'">
<span class="tw-sr-only">(</span>
<span [ngClass]="status.text === 'active' ? 'tw-font-bold tw-text-success' : ''">{{
<span [ngClass]="status.text === 'active' ? 'tw-font-medium tw-text-success' : ''">{{
status.text
}}</span>
<span class="tw-sr-only">)</span>

View File

@@ -6,10 +6,13 @@ import {
MessageListener,
MessageSender,
} from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { newGuid } from "@bitwarden/guid";
import { UserId } from "@bitwarden/user-core";
const LOCK_ALL_FINISHED = new CommandDefinition<{ requestId: string }>("lockAllFinished");
const LOCK_ALL = new CommandDefinition<{ requestId: string }>("lockAll");
const LOCK_USER_FINISHED = new CommandDefinition<{ requestId: string }>("lockUserFinished");
const LOCK_USER = new CommandDefinition<{ requestId: string; userId: UserId }>("lockUser");
export class ForegroundLockService implements LockService {
constructor(
@@ -18,7 +21,7 @@ export class ForegroundLockService implements LockService {
) {}
async lockAll(): Promise<void> {
const requestId = Utils.newGuid();
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_ALL_FINISHED)
@@ -29,4 +32,19 @@ export class ForegroundLockService implements LockService {
await finishMessage;
}
async lock(userId: UserId): Promise<void> {
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_USER_FINISHED)
.pipe(filter((m) => m.requestId === requestId)),
);
this.messageSender.send(LOCK_USER, { requestId, userId });
await finishMessage;
}
async runPlatformOnLockActions(): Promise<void> {}
}

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setPinForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "setYourPinTitle" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -6,6 +6,7 @@ import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { LockService } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -16,7 +17,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutSettingsService,
VaultTimeoutService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -63,6 +63,7 @@ describe("AccountSecurityComponent", () => {
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const lockService = mock<LockService>();
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -83,7 +84,6 @@ describe("AccountSecurityComponent", () => {
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutService, useValue: mock<VaultTimeoutService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{ provide: CipherService, useValue: mock<CipherService>() },
@@ -92,6 +92,7 @@ describe("AccountSecurityComponent", () => {
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
],
})
.overrideComponent(AccountSecurityComponent, {

View File

@@ -25,6 +25,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
import { LockService } from "@bitwarden/auth/common";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
@@ -36,7 +37,6 @@ import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutService,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -143,7 +143,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private vaultTimeoutService: VaultTimeoutService,
private lockService: LockService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
public messagingService: MessagingService,
private environmentService: EnvironmentService,
@@ -695,7 +695,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
}
async lock() {
await this.vaultTimeoutService.lock();
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
}
async logOut() {

View File

@@ -0,0 +1,58 @@
import { DefaultLockService, LogoutService } from "@bitwarden/auth/common";
import MainBackground from "@bitwarden/browser/background/main.background";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { BiometricsService, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { StateEventRunnerService } from "@bitwarden/state";
export class ExtensionLockService extends DefaultLockService {
constructor(
accountService: AccountService,
biometricService: BiometricsService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
logoutService: LogoutService,
messagingService: MessagingService,
searchService: SearchService,
folderService: FolderService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
stateEventRunnerService: StateEventRunnerService,
cipherService: CipherService,
authService: AuthService,
systemService: SystemService,
processReloadService: ProcessReloadServiceAbstraction,
logService: LogService,
keyService: KeyService,
private readonly main: MainBackground,
) {
super(
accountService,
biometricService,
vaultTimeoutSettingsService,
logoutService,
messagingService,
searchService,
folderService,
masterPasswordService,
stateEventRunnerService,
cipherService,
authService,
systemService,
processReloadService,
logService,
keyService,
);
}
async runPlatformOnLockActions(): Promise<void> {
await this.main.refreshMenu(true);
}
}

View File

@@ -68,7 +68,7 @@ const actionButtonStyles = ({
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
font-weight: 700;
font-weight: 500;
${disabled || isLoading
? `

View File

@@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css`
${baseTextStyles}
color: ${themes[theme].primary[600]};
font-weight: 700;
font-weight: 500;
cursor: pointer;
`;

View File

@@ -21,5 +21,5 @@ const notificationHeaderMessageStyles = (theme: Theme) => css`
color: ${themes[theme].text.main};
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
font-weight: 500;
`;

View File

@@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css`
user-select: none;
padding: 0.375rem ${spacing["3"]};
color: ${themes[theme].text.muted};
font-weight: 600;
font-weight: 500;
`;
export const optionsMenuItemMaxWidth = 260;

View File

@@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css`
min-height: 40px;
text-align: left;
color: ${themes[theme].primary["600"]};
font-weight: 700;
font-weight: 500;
> span {
display: block;

View File

@@ -82,7 +82,7 @@ body * {
width: 100%;
font-family: $font-family-sans-serif;
font-size: 1.6rem;
font-weight: 700;
font-weight: 500;
text-align: left;
background: transparent;
border: none;
@@ -187,7 +187,7 @@ body * {
top: 0;
z-index: 1;
font-family: $font-family-sans-serif;
font-weight: 600;
font-weight: 500;
font-size: 1rem;
line-height: 1.3;
letter-spacing: 0.025rem;

View File

@@ -1,9 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
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 { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
// FIXME (PM-22628): Popup imports are forbidden in background
@@ -21,9 +25,10 @@ export default class CommandsBackground {
constructor(
private main: MainBackground,
private platformUtilsService: PlatformUtilsService,
private vaultTimeoutService: VaultTimeoutService,
private authService: AuthService,
private generatePasswordToClipboard: () => Promise<void>,
private accountService: AccountService,
private lockService: LockService,
) {
this.isSafari = this.platformUtilsService.isSafari();
this.isVivaldi = this.platformUtilsService.isVivaldi();
@@ -72,9 +77,11 @@ export default class CommandsBackground {
case "open_popup":
await this.openPopup();
break;
case "lock_vault":
await this.vaultTimeoutService.lock();
case "lock_vault": {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
break;
}
default:
break;
}

View File

@@ -1,6 +1,6 @@
import { firstValueFrom } from "rxjs";
import { LogoutService } from "@bitwarden/auth/common";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutAction,
@@ -23,6 +23,7 @@ export default class IdleBackground {
private serverNotificationsService: ServerNotificationsService,
private accountService: AccountService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private lockService: LockService,
private logoutService: LogoutService,
) {
this.idle = chrome.idle || (browser != null ? browser.idle : null);
@@ -66,7 +67,7 @@ export default class IdleBackground {
if (action === VaultTimeoutAction.LogOut) {
await this.logoutService.logout(userId as UserId, "vaultTimeout");
} else {
await this.vaultTimeoutService.lock(userId);
await this.lockService.lock(userId as UserId);
}
}
}

View File

@@ -20,9 +20,9 @@ import {
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LockService,
LoginEmailServiceAbstraction,
LogoutReason,
UserDecryptionOptionsService,
@@ -270,6 +270,7 @@ import {
} from "@bitwarden/vault-export-core";
import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service";
import { ExtensionLockService } from "../auth/services/extension-lock.service";
import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background";
import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background";
import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background";
@@ -363,6 +364,7 @@ export default class MainBackground {
folderService: InternalFolderServiceAbstraction;
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
collectionService: CollectionService;
lockService: LockService;
vaultTimeoutService?: VaultTimeoutService;
vaultTimeoutSettingsService: VaultTimeoutSettingsService;
passwordGenerationService: PasswordGenerationServiceAbstraction;
@@ -496,16 +498,6 @@ export default class MainBackground {
private phishingDataService: PhishingDataService;
constructor() {
// Services
const lockedCallback = async (userId: UserId) => {
await this.refreshMenu(true);
if (this.systemService != null) {
await this.systemService.clearPendingClipboard();
await this.biometricsService.setShouldAutopromptNow(false);
await this.processReloadService.startProcessReload(this.authService);
}
};
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
await this.logout(logoutReason, userId);
@@ -987,27 +979,6 @@ export default class MainBackground {
this.restrictedItemTypesService,
);
const logoutService = new DefaultLogoutService(this.messagingService);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
this.biometricsService,
lockedCallback,
logoutService,
);
this.containerService = new ContainerService(this.keyService, this.encryptService);
this.sendStateProvider = new SendStateProvider(this.stateProvider);
@@ -1271,6 +1242,7 @@ export default class MainBackground {
this.biometricStateService,
this.accountService,
this.logService,
this.authService,
);
// Background
@@ -1284,7 +1256,36 @@ export default class MainBackground {
this.authService,
);
const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService);
const logoutService = new DefaultLogoutService(this.messagingService);
this.lockService = new ExtensionLockService(
this.accountService,
this.biometricsService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
this.systemService,
this.processReloadService,
this.logService,
this.keyService,
this,
);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.platformUtilsService,
this.authService,
this.vaultTimeoutSettingsService,
this.taskSchedulerService,
this.logService,
this.lockService,
logoutService,
);
this.runtimeBackground = new RuntimeBackground(
this,
@@ -1298,7 +1299,7 @@ export default class MainBackground {
this.configService,
messageListener,
this.accountService,
lockService,
this.lockService,
this.billingAccountProfileStateService,
this.browserInitialInstallService,
);
@@ -1318,9 +1319,10 @@ export default class MainBackground {
this.commandsBackground = new CommandsBackground(
this,
this.platformUtilsService,
this.vaultTimeoutService,
this.authService,
() => this.generatePasswordToClipboard(),
this.accountService,
this.lockService,
);
this.taskService = new DefaultTaskService(
@@ -1405,6 +1407,7 @@ export default class MainBackground {
this.serverNotificationsService,
this.accountService,
this.vaultTimeoutSettingsService,
this.lockService,
logoutService,
);
@@ -1752,7 +1755,7 @@ export default class MainBackground {
}
await this.mainContextMenuHandler?.noAccess();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
}
private async needsStorageReseed(userId: UserId): Promise<boolean> {

View File

@@ -257,7 +257,7 @@ export default class RuntimeBackground {
this.lockedVaultPendingNotifications.push(msg.data);
break;
case "lockVault":
await this.main.vaultTimeoutService.lock(msg.userId);
await this.lockService.lock(msg.userId);
break;
case "lockAll":
{
@@ -265,6 +265,14 @@ export default class RuntimeBackground {
this.messagingService.send("lockAllFinished", { requestId: msg.requestId });
}
break;
case "lockUser":
{
await this.lockService.lock(msg.userId);
this.messagingService.send("lockUserFinished", {
requestId: msg.requestId,
});
}
break;
case "logout":
await this.main.logout(msg.expired, msg.userId);
break;

View File

@@ -2,15 +2,10 @@
// @ts-strict-ignore
import { VaultTimeoutService as BaseVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout/abstractions/vault-timeout.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService {
constructor(protected messagingService: MessagingService) {}
// should only ever run in background
async checkVaultTimeout(): Promise<void> {}
async lock(userId?: UserId): Promise<void> {
this.messagingService.send("lockVault", { userId });
}
}

View File

@@ -32,6 +32,36 @@ export class BrowserApi {
return BrowserApi.manifestVersion === expectedVersion;
}
static senderIsInternal(sender: chrome.runtime.MessageSender | undefined): boolean {
if (!sender?.url) {
return false;
}
const extensionUrl =
(typeof chrome !== "undefined" && chrome.runtime?.getURL("")) ||
(typeof browser !== "undefined" && browser.runtime?.getURL("")) ||
"";
if (!extensionUrl) {
return false;
}
if (!sender.url.startsWith(extensionUrl)) {
return false;
}
// these are all properties on externally initiated messages, not internal ones
if (
"tab" in sender ||
"documentId" in sender ||
"documentLifecycle" in sender ||
"frameId" in sender
) {
return false;
}
return true;
}
/**
* Gets all open browser windows, including their tabs.
*

View File

@@ -140,6 +140,11 @@ describe("BrowserPopupUtils", () => {
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({
os: "linux",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
@@ -150,6 +155,8 @@ describe("BrowserPopupUtils", () => {
width: 380,
});
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation();
});
it("creates a window with the default window options", async () => {
@@ -267,6 +274,63 @@ describe("BrowserPopupUtils", () => {
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "mac",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
jest
.spyOn(BrowserApi, "createWindow")
.mockResolvedValueOnce({ id: 2 } as chrome.windows.Window);
await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 });
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, {
state: "maximized",
});
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
focused: true,
});
});
it("doesnt exit fullscreen if the platform is not mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "win",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, {
state: "maximized",
});
});
});
describe("openCurrentPagePopout", () => {

View File

@@ -168,8 +168,29 @@ export default class BrowserPopupUtils {
) {
return;
}
const platform = await BrowserApi.getPlatformInfo();
const isMacOS = platform.os === "mac";
const isFullscreen = senderWindow.state === "fullscreen";
const isFullscreenAndMacOS = isFullscreen && isMacOS;
//macOS specific handling for improved UX when sender in fullscreen aka green button;
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(senderWindow.id, {
state: "maximized",
});
return await BrowserApi.createWindow(popoutWindowOptions);
//wait for macOS animation to finish
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const newWindow = await BrowserApi.createWindow(popoutWindowOptions);
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(newWindow.id, {
focused: true,
});
}
return newWindow;
}
/**

View File

@@ -29,11 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
} from "@bitwarden/components";
import { VaultLoadingSkeletonComponent } from "../../../vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
import { PopupFooterComponent } from "./popup-footer.component";
@@ -366,9 +364,7 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
VaultLoadingSkeletonComponent,
],
providers: [
{
@@ -634,21 +630,9 @@ export const SkeletonLoading: Story = {
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<popup-page>
<popup-page hideOverflow>
<popup-header slot="header" pageTitle="Page Header"></popup-header>
<div>
<div class="tw-sr-only" role="status">Loading...</div>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-1/3"></bit-skeleton-text>
@for (num of data; track $index) {
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
<bit-skeleton class="tw-w-full tw-h-[1px]"></bit-skeleton>
}
</div>
</div>
<vault-loading-skeleton></vault-loading-skeleton>
</popup-page>
</popup-tab-navigation>
</extension-container>

View File

@@ -1,7 +1,7 @@
<ng-content select="[slot=header]"></ng-content>
<main class="tw-flex-1 tw-overflow-hidden tw-flex tw-flex-col tw-relative tw-bg-background-alt">
<ng-content select="[slot=full-width-notice]"></ng-content>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
@@ -10,26 +10,28 @@
#nonScrollable
class="tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]"
[ngClass]="{
'tw-invisible !tw-p-0 !tw-border-none': loading || nonScrollable.childElementCount === 0,
'tw-invisible !tw-p-0 !tw-border-none': loading() || nonScrollable.childElementCount === 0,
'tw-border-secondary-300': scrolled(),
'tw-border-transparent': !scrolled(),
}"
>
<ng-content select="[slot=above-scroll-area]"></ng-content>
</div>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
-->
<div
class="tw-overflow-y-auto tw-size-full tw-styled-scrollbar"
class="tw-size-full tw-styled-scrollbar"
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
'tw-invisible': loading,
'tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),
'tw-invisible': loading(),
'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
!disablePadding,
!disablePadding(),
}"
bitScrollLayoutHost
>
@@ -37,9 +39,9 @@
</div>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-text-main"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !loading() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText"></i>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText()"></i>
</span>
</main>
<ng-content select="[slot=footer]"></ng-content>

View File

@@ -1,11 +1,16 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, inject, Input, signal } from "@angular/core";
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ScrollLayoutHostDirective } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-page",
templateUrl: "popup-page.component.html",
@@ -13,28 +18,23 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components";
class: "tw-h-full tw-flex tw-flex-col tw-overflow-y-hidden",
},
imports: [CommonModule, ScrollLayoutHostDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PopupPageComponent {
protected i18nService = inject(I18nService);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading = false;
readonly loading = input<boolean>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
disablePadding = false;
readonly disablePadding = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected scrolled = signal(false);
/** Hides any overflow within the page content */
readonly hideOverflow = input(false, { transform: booleanAttribute });
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
/** Accessible loading label for the spinner. Defaults to "loading" */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loadingText?: string = this.i18nService.t("loading");
readonly loadingText = input<string | undefined>(this.i18nService.t("loading"));
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);

View File

@@ -43,6 +43,9 @@ export class LocalBackedSessionStorageService
if (port.name !== portName(chrome.storage.session)) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender)) {
return;
}
this.ports.add(port);

View File

@@ -141,7 +141,9 @@ export class PopupViewCacheBackgroundService {
// on popup closed, with 2 minute delay that is cancelled by re-opening the popup
fromChromeEvent(chrome.runtime.onConnect)
.pipe(
filter(([port]) => port.name === popupClosedPortName),
filter(
([port]) => port.name === popupClosedPortName && BrowserApi.senderIsInternal(port.sender),
),
switchMap(([port]) =>
fromChromeEvent(port.onDisconnect).pipe(
delay(

View File

@@ -19,6 +19,24 @@ import {
import { BackgroundTaskSchedulerService } from "./background-task-scheduler.service";
function createInternalPortSpyMock(name: string) {
return mock<chrome.runtime.Port>({
name,
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onDisconnect: {
addListener: jest.fn(),
},
postMessage: jest.fn(),
disconnect: jest.fn(),
sender: {
url: chrome.runtime.getURL(""),
},
});
}
describe("BackgroundTaskSchedulerService", () => {
let logService: MockProxy<LogService>;
let stateProvider: MockProxy<StateProvider>;
@@ -35,7 +53,7 @@ describe("BackgroundTaskSchedulerService", () => {
stateProvider = mock<StateProvider>({
getGlobal: jest.fn(() => globalStateMock),
});
portMock = createPortSpyMock(BrowserTaskSchedulerPortName);
portMock = createInternalPortSpyMock(BrowserTaskSchedulerPortName);
backgroundTaskSchedulerService = new BackgroundTaskSchedulerService(logService, stateProvider);
jest.spyOn(globalThis, "setTimeout");
});

View File

@@ -30,6 +30,9 @@ export class BackgroundTaskSchedulerService extends BrowserTaskSchedulerServiceI
if (port.name !== BrowserTaskSchedulerPortName) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender)) {
return;
}
this.ports.add(port);
port.onMessage.addListener(this.handlePortMessage);

View File

@@ -18,6 +18,9 @@ export class BackgroundMemoryStorageService extends SerializedMemoryStorageServi
if (port.name !== portName(chrome.storage.session)) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender)) {
return;
}
this._ports.push(port);

View File

@@ -10,7 +10,8 @@ import { mockPorts } from "../../../spec/mock-port.spec-util";
import { BackgroundMemoryStorageService } from "./background-memory-storage.service";
import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service";
describe("foreground background memory storage interaction", () => {
// These are succeeding individually but failing in a batch run - skipping for now
describe.skip("foreground background memory storage interaction", () => {
let foreground: ForegroundMemoryStorageService;
let background: BackgroundMemoryStorageService;

View File

@@ -0,0 +1,15 @@
<section aria-hidden="true">
<div class="tw-mt-1.5 tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-[8.625rem] tw-max-w-full tw-mb-2.5"></bit-skeleton-text>
@for (num of numberOfItems; track $index) {
<bit-skeleton-group class="tw-mx-2">
<bit-skeleton class="tw-size-6" slot="start"></bit-skeleton>
<div class="tw-flex tw-flex-col tw-gap-1">
<bit-skeleton class="tw-w-40 tw-h-2.5 tw-max-w-full"></bit-skeleton>
<bit-skeleton class="tw-w-24 tw-h-2.5 tw-max-w-full"></bit-skeleton>
</div>
</bit-skeleton-group>
<hr class="tw-h-[1px] -tw-mr-3 tw-bg-secondary-100 tw-border-none" />
}
</div>
</section>

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import {
SkeletonComponent,
SkeletonGroupComponent,
SkeletonTextComponent,
} from "@bitwarden/components";
@Component({
selector: "vault-loading-skeleton",
templateUrl: "./vault-loading-skeleton.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonGroupComponent, SkeletonComponent, SkeletonTextComponent],
})
export class VaultLoadingSkeletonComponent {
protected readonly numberOfItems: null[] = new Array(15).fill(null);
}

View File

@@ -291,7 +291,7 @@ export class ItemMoreOptionsComponent {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),
});
}

View File

@@ -1,16 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
export class LockCommand {
constructor(private vaultTimeoutService: VaultTimeoutService) {}
constructor(
private lockService: LockService,
private accountService: AccountService,
) {}
async run() {
await this.vaultTimeoutService.lock();
process.env.BW_SESSION = null;
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
process.env.BW_SESSION = undefined;
const res = new MessageResponse("Your vault is locked.", null);
return Response.success(res);
}

View File

@@ -0,0 +1,10 @@
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
/**
* CLI implementation of ProcessReloadServiceAbstraction.
* This is NOOP since there is no effective way to process reload the CLI.
*/
export class CliProcessReloadService extends ProcessReloadServiceAbstraction {
async startProcessReload(): Promise<void> {}
async cancelProcessReload(): Promise<void> {}
}

View File

@@ -160,7 +160,10 @@ export class OssServeConfigurator {
this.serviceContainer.cipherService,
this.serviceContainer.accountService,
);
this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService);
this.lockCommand = new LockCommand(
serviceContainer.lockService,
serviceContainer.accountService,
);
this.unlockCommand = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,

View File

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* CLI implementation of SystemService.
* The implementation is NOOP since these functions are meant for GUI clients.
*/
export class CliSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -250,7 +250,10 @@ export class Program extends BaseProgram {
return;
}
const command = new LockCommand(this.serviceContainer.vaultTimeoutService);
const command = new LockCommand(
this.serviceContainer.lockService,
this.serviceContainer.accountService,
);
const response = await command.run();
this.processResponse(response);
});

View File

@@ -20,6 +20,9 @@ import {
SsoUrlService,
AuthRequestApiServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
LockService,
} from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
@@ -199,9 +202,11 @@ import {
} from "@bitwarden/vault-export-core";
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { CliProcessReloadService } from "../key-management/cli-process-reload.service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service";
import { CliSystemService } from "../platform/services/cli-system.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
@@ -318,6 +323,7 @@ export class ServiceContainer {
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
lockService: LockService;
constructor() {
let p = null;
@@ -778,9 +784,6 @@ export class ServiceContainer {
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
const lockedCallback = async (userId: UserId) =>
await this.keyService.clearStoredUserKey(userId);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
this.userVerificationService = new UserVerificationService(
@@ -796,25 +799,35 @@ export class ServiceContainer {
);
const biometricService = new CliBiometricsService();
const logoutService = new DefaultLogoutService(this.messagingService);
const processReloadService = new CliProcessReloadService();
const systemService = new CliSystemService();
this.lockService = new DefaultLockService(
this.accountService,
biometricService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
systemService,
processReloadService,
this.logService,
this.keyService,
);
this.vaultTimeoutService = new DefaultVaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
biometricService,
lockedCallback,
this.lockService,
undefined,
);

View File

@@ -92,18 +92,18 @@ export class CreateCommand {
}
private async createCipher(req: CipherExport) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
const newCipher = await this.cipherService.createWithServer(cipher);
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
const res = new CipherResponse(decCipher);

View File

@@ -444,8 +444,10 @@ dependencies = [
name = "bitwarden_chromium_import_helper"
version = "0.0.0"
dependencies = [
"aes-gcm",
"anyhow",
"base64",
"chacha20poly1305",
"chromium_importer",
"clap",
"embed-resource",
@@ -606,7 +608,6 @@ dependencies = [
"async-trait",
"base64",
"cbc",
"chacha20poly1305",
"dirs",
"hex",
"oo7",

View File

@@ -20,6 +20,7 @@ publish = false
[workspace.dependencies]
aes = "=0.8.4"
aes-gcm = "=0.10.3"
anyhow = "=1.0.94"
arboard = { version = "=3.6.0", default-features = false }
ashpd = "=0.11.0"

View File

@@ -8,23 +8,14 @@ publish.workspace = true
[dependencies]
[target.'cfg(target_os = "windows")'.dependencies]
aes-gcm = { workspace = true }
chacha20poly1305 = { workspace = true }
chromium_importer = { path = "../chromium_importer" }
clap = { version = "=4.5.40", features = ["derive"] }
scopeguard = { workspace = true }
sysinfo = { workspace = true }
windows = { workspace = true, features = [
"Wdk_System_SystemServices",
"Win32_Security_Cryptography",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_ProcessStatus",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
anyhow = { workspace = true }
base64 = { workspace = true }

View File

@@ -1,482 +0,0 @@
mod windows_binary {
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use clap::Parser;
use scopeguard::defer;
use std::{
ffi::OsString,
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
path::{Path, PathBuf},
ptr,
time::Duration,
};
use sysinfo::System;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
time,
};
use tracing::{debug, error, level_filters::LevelFilter};
use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
use windows::{
core::BOOL,
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
Win32::{
Foundation::{
CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS,
},
Security::{
self,
Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB},
DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
TOKEN_QUERY,
},
System::{
Pipes::GetNamedPipeServerProcessId,
Threading::{
OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
},
},
UI::Shell::IsUserAnAdmin,
},
};
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
#[derive(Parser)]
#[command(name = "bitwarden_chromium_import_helper")]
#[command(about = "Admin tool for ABE service management")]
struct Args {
/// Base64 encoded encrypted data to process
#[arg(long, help = "Base64 encoded encrypted data string")]
encrypted: String,
}
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
// This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to
// no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally
// all the logging code is present in the release build and could be enabled via RUST_LOG environment variable.
// We don't want that!
const ENABLE_DEVELOPER_LOGGING: bool = false;
const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
// This should be enabled for production
const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true;
// List of SYSTEM process names to try to impersonate
const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];
// Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false
macro_rules! dbg_log {
($($arg:tt)*) => {
if ENABLE_DEVELOPER_LOGGING {
debug!($($arg)*);
}
};
}
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
let max_attempts = 5;
for _ in 0..max_attempts {
match ClientOptions::new().open(pipe_name) {
Ok(client) => {
dbg_log!("Successfully connected to the pipe!");
return Ok(client);
}
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
dbg_log!("Pipe is busy, retrying in 50ms...");
}
Err(e) => {
dbg_log!("Failed to connect to pipe: {}", &e);
return Err(e.into());
}
}
time::sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"Failed to connect to pipe after {} attempts",
max_attempts
))
}
async fn send_message_with_client(
client: &mut NamedPipeClient,
message: &str,
) -> Result<String> {
client.write_all(message.as_bytes()).await?;
// Try to receive a response for this message
let mut buffer = vec![0u8; 64 * 1024];
match client.read(&mut buffer).await {
Ok(0) => Err(anyhow!(
"Server closed the connection (0 bytes read) on message"
)),
Ok(bytes_received) => {
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
Ok(response.to_string())
}
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
}
}
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
let handle = HANDLE(client.as_raw_handle() as _);
let mut pid: u32 = 0;
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
Ok(pid)
}
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
dbg_log!("Resolving process executable path for PID {}", pid);
// Open the process handle
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
dbg_log!("Opened process handle for PID {}", pid);
// Close when no longer needed
defer! {
dbg_log!("Closing process handle for PID {}", pid);
unsafe {
_ = CloseHandle(hprocess);
}
};
let mut exe_name = vec![0u16; 32 * 1024];
let mut exe_name_length = exe_name.len() as u32;
unsafe {
QueryFullProcessImageNameW(
hprocess,
PROCESS_NAME_WIN32,
windows::core::PWSTR(exe_name.as_mut_ptr()),
&mut exe_name_length,
)
}?;
dbg_log!(
"QueryFullProcessImageNameW returned {} bytes",
exe_name_length
);
exe_name.truncate(exe_name_length as usize);
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
}
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
_ = send_to_user(client, &format!("!{}", error_message)).await
}
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
let _ = send_message_with_client(client, message).await?;
Ok(())
}
fn is_admin() -> bool {
unsafe { IsUserAnAdmin().as_bool() }
}
fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result<String> {
dbg_log!("Decrypting data base64: {}", data_base64);
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb);
e
})?;
let decrypted = decrypt_data(&data, expect_appb)?;
let decrypted_base64 = general_purpose::STANDARD.encode(decrypted);
Ok(decrypted_base64)
}
fn decrypt_data(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
if expect_appb && !data.starts_with(b"APPB") {
dbg_log!("Decoded data does not start with 'APPB'");
return Err(anyhow!("Decoded data does not start with 'APPB'"));
}
let data = if expect_appb { &data[4..] } else { data };
let in_blob = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: ptr::null_mut(),
};
let result = unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
let decrypted = unsafe {
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
};
// Free the memory allocated by CryptUnprotectData
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
Ok(decrypted)
} else {
dbg_log!("CryptUnprotectData failed");
Err(anyhow!("CryptUnprotectData failed"))
}
}
//
// Impersonate a SYSTEM process
//
fn start_impersonating() -> Result<HANDLE> {
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
enable_debug_privilege()?;
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
// Impersonate the SYSTEM process
unsafe {
ImpersonateLoggedOnUser(token)?;
};
dbg_log!("Impersonating system process '{}' (PID: {})", name, pid);
Ok(token)
}
fn stop_impersonating(token: HANDLE) -> Result<()> {
unsafe {
RevertToSelf()?;
CloseHandle(token)?;
};
Ok(())
}
fn find_system_process_with_token(
pids: Vec<(u32, &'static str)>,
) -> Result<(HANDLE, u32, &'static str)> {
for (pid, name) in pids {
match get_system_token_from_pid(pid) {
Err(_) => {
dbg_log!(
"Failed to open process handle '{}' (PID: {}), skipping",
name,
pid
);
continue;
}
Ok(system_handle) => {
return Ok((system_handle, pid, name));
}
}
}
Err(anyhow!("Failed to get system token from any process"))
}
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
let handle = get_process_handle(pid)?;
let token = get_system_token(handle)?;
unsafe {
CloseHandle(handle)?;
};
Ok(token)
}
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
let token_handle = unsafe {
let mut token_handle = HANDLE::default();
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
token_handle
};
let duplicate_token = unsafe {
let mut duplicate_token = HANDLE::default();
DuplicateToken(
token_handle,
Security::SECURITY_IMPERSONATION_LEVEL(2),
&mut duplicate_token,
)?;
CloseHandle(token_handle)?;
duplicate_token
};
Ok(duplicate_token)
}
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
let sys = System::new_all();
SYSTEM_PROCESS_NAMES
.iter()
.flat_map(|&name| {
sys.processes_by_exact_name(name.as_ref())
.map(move |process| (process.pid().as_u32(), name))
})
.collect()
}
fn get_process_handle(pid: u32) -> Result<HANDLE> {
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
Ok(hprocess)
}
#[link(name = "ntdll")]
unsafe extern "system" {
unsafe fn RtlAdjustPrivilege(
privilege: i32,
enable: BOOL,
current_thread: BOOL,
previous_value: *mut BOOL,
) -> NTSTATUS;
}
fn enable_debug_privilege() -> Result<()> {
let mut previous_value = BOOL(0);
let status = unsafe {
dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
};
match status {
STATUS_SUCCESS => {
dbg_log!(
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
previous_value.as_bool()
);
Ok(())
}
_ => {
dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
Err(anyhow!("Failed to adjust privilege"))
}
}
}
//
// Pipe
//
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
let client = open_pipe_client(pipe_name).await?;
if ENABLE_SERVER_SIGNATURE_VALIDATION {
let server_pid = get_named_pipe_server_pid(&client)?;
dbg_log!("Connected to pipe server PID {}", server_pid);
// Validate the server end process signature
let exe_path = resolve_process_executable_path(server_pid)?;
dbg_log!("Pipe server executable path: {}", exe_path.display());
if !verify_signature(&exe_path)? {
return Err(anyhow!("Pipe server signature is not valid"));
}
dbg_log!("Pipe server signature verified for PID {}", server_pid);
}
Ok(client)
}
fn run() -> Result<String> {
dbg_log!("Starting bitwarden_chromium_import_helper.exe");
let args = Args::try_parse()?;
if !is_admin() {
return Err(anyhow!("Expected to run with admin privileges"));
}
dbg_log!("Running as ADMINISTRATOR");
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
let system_decrypted_base64 = {
let system_token = start_impersonating()?;
defer! {
dbg_log!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?;
dbg_log!("Decrypted data with system");
system_decrypted_base64
};
// This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor.
// Now that we're back from SYSTEM, we need to decrypt one more time just to verify.
// Chrome keys are double encrypted: once at SYSTEM level and once at USER level.
// When the decryption fails, it means that we're decrypting something unexpected.
// We don't send this result back since the library will decrypt again at USER level.
_ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| {
dbg_log!("User level decryption check failed: {}", e);
e
})?;
dbg_log!("User level decryption check passed");
Ok(system_decrypted_base64)
}
fn init_logging(log_path: &Path, file_level: LevelFilter) {
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
match std::fs::File::create(log_path) {
Ok(file) => {
let file_filter = EnvFilter::builder()
.with_default_directive(file_level.into())
.from_env_lossy();
let file_layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.with_filter(file_filter);
tracing_subscriber::registry().with(file_layer).init();
}
Err(error) => {
error!(%error, ?log_path, "Could not create log file.");
}
}
}
pub(crate) async fn main() {
if ENABLE_DEVELOPER_LOGGING {
init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG);
}
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
Ok(client) => client,
Err(e) => {
error!(
"Failed to open pipe {} to send result/error: {}",
ADMIN_TO_USER_PIPE_NAME, e
);
return;
}
};
match run() {
Ok(system_decrypted_base64) => {
dbg_log!("Sending response back to user");
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
}
Err(e) => {
dbg_log!("Error: {}", e);
send_error_to_user(&mut client, &format!("{}", e)).await;
}
}
}
}
pub(crate) use windows_binary::*;

View File

@@ -0,0 +1,10 @@
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
// This is intended for development time only.
pub(crate) const ENABLE_DEVELOPER_LOGGING: bool = false;
pub(crate) const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
// This should be enabled for production
pub(crate) const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true;
// List of SYSTEM process names to try to impersonate
pub(crate) const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];

View File

@@ -0,0 +1,312 @@
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use chacha20poly1305::ChaCha20Poly1305;
use scopeguard::defer;
use tracing::debug;
use windows::{
core::w,
Win32::{
Foundation::{LocalFree, HLOCAL},
Security::Cryptography::{
self, CryptUnprotectData, NCryptOpenKey, NCryptOpenStorageProvider, CERT_KEY_SPEC,
CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, NCRYPT_FLAGS, NCRYPT_KEY_HANDLE,
NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG,
},
},
};
use super::impersonate::{start_impersonating, stop_impersonating};
//
// Base64
//
pub(crate) fn decode_base64(data_base64: &str) -> Result<Vec<u8>> {
debug!("Decoding base64 data: {}", data_base64);
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
debug!("Failed to decode base64: {}", e);
e
})?;
Ok(data)
}
pub(crate) fn encode_base64(data: &[u8]) -> String {
general_purpose::STANDARD.encode(data)
}
//
// DPAPI decryption
//
pub(crate) fn decrypt_with_dpapi_as_system(encrypted: &[u8]) -> Result<Vec<u8>> {
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
let system_token = start_impersonating()?;
defer! {
debug!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
decrypt_with_dpapi_as_user(encrypted, true)
}
pub(crate) fn decrypt_with_dpapi_as_user(encrypted: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
let system_decrypted = decrypt_with_dpapi(encrypted, expect_appb)?;
debug!(
"Decrypted data with SYSTEM {} bytes",
system_decrypted.len()
);
Ok(system_decrypted)
}
fn decrypt_with_dpapi(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
if expect_appb && (data.len() < 5 || !data.starts_with(b"APPB")) {
const ERR_MSG: &str = "Ciphertext is too short or does not start with 'APPB'";
debug!("{}", ERR_MSG);
return Err(anyhow!(ERR_MSG));
}
let data = if expect_appb { &data[4..] } else { data };
let in_blob = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB::default();
let result = unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
let decrypted = unsafe {
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
};
// Free the memory allocated by CryptUnprotectData
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
Ok(decrypted)
} else {
debug!("CryptUnprotectData failed");
Err(anyhow!("CryptUnprotectData failed"))
}
}
//
// Chromium key decoding
//
pub(crate) fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
// Parse and skip the header
let header_len = u32::from_le_bytes(get_safe(blob_data, 0, 4)?.try_into()?) as usize;
debug!("ABE key blob header length: {}", header_len);
// Parse content length
let content_len_offset = 4 + header_len;
let content_len =
u32::from_le_bytes(get_safe(blob_data, content_len_offset, 4)?.try_into()?) as usize;
debug!("ABE key blob content length: {}", content_len);
if content_len < 32 {
return Err(anyhow!(
"Corrupted ABE key blob: content length is less than 32"
));
}
let content_offset = content_len_offset + 4;
let content = get_safe(blob_data, content_offset, content_len)?;
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
if content_len == 32 {
return Ok(content.to_vec());
}
let version = content[0];
debug!("ABE key blob version: {}", version);
let key_blob = &content[1..];
match version {
// Google Chrome v1 key encrypted with a hardcoded AES key
1_u8 => decrypt_abe_key_blob_chrome_aes(key_blob),
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
2_u8 => decrypt_abe_key_blob_chrome_chacha20(key_blob),
// Google Chrome v3 key encrypted with CNG APIs
3_u8 => decrypt_abe_key_blob_chrome_cng(key_blob),
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
}
}
fn get_safe(data: &[u8], start: usize, len: usize) -> Result<&[u8]> {
let end = start + len;
data.get(start..end).ok_or_else(|| {
anyhow!(
"Corrupted ABE key blob: expected bytes {}..{}, got {}",
start,
end,
data.len()
)
})
}
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
const GOOGLE_AES_KEY: &[u8] = &[
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, 0x66,
0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, 0xA0, 0x28,
0x47, 0x87,
];
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
let cipher = Aes256Gcm::new(aes_key);
decrypt_abe_key_blob_with_aead(blob, &cipher, "v1 (AES flavor)")
}
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
const GOOGLE_CHACHA20_KEY: &[u8] = &[
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, 0x80,
0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, 0x08, 0x72,
0x96, 0x60,
];
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
let cipher = ChaCha20Poly1305::new(chacha20_key);
decrypt_abe_key_blob_with_aead(blob, &cipher, "v2 (ChaCha20 flavor)")
}
fn decrypt_abe_key_blob_with_aead<C>(blob: &[u8], cipher: &C, version: &str) -> Result<Vec<u8>>
where
C: Aead,
{
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let iv = &blob[0..12];
let ciphertext = &blob[12..12 + 48];
debug!("Google ABE {} detected: {:?} {:?}", version, iv, ciphertext);
let decrypted = cipher
.decrypt(iv.into(), ciphertext)
.map_err(|e| anyhow!("Failed to decrypt v20 key with {}: {}", version, e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 92 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
blob.len()
));
}
let encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
let iv: [u8; 12] = blob[32..32 + 12].try_into()?;
let ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
debug!(
"Google ABE v3 (CNG flavor) detected: {:?} {:?} {:?}",
encrypted_aes_key, iv, ciphertext
);
// First, decrypt the AES key with CNG API
let decrypted_aes_key: Vec<u8> = {
let system_token = start_impersonating()?;
defer! {
debug!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
decrypt_with_cng(&encrypted_aes_key)?
};
const GOOGLE_XOR_KEY: [u8; 32] = [
0xCC, 0xF8, 0xA1, 0xCE, 0xC5, 0x66, 0x05, 0xB8, 0x51, 0x75, 0x52, 0xBA, 0x1A, 0x2D, 0x06,
0x1C, 0x03, 0xA2, 0x9E, 0x90, 0x27, 0x4F, 0xB2, 0xFC, 0xF5, 0x9B, 0xA4, 0xB7, 0x5C, 0x39,
0x23, 0x90,
];
// XOR the decrypted AES key with the hardcoded key
let aes_key: Vec<u8> = decrypted_aes_key
.into_iter()
.zip(GOOGLE_XOR_KEY)
.map(|(a, b)| a ^ b)
.collect();
// Decrypt the actual ABE key with the decrypted AES key
let cipher = Aes256Gcm::new(aes_key.as_slice().into());
let key = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with AES-GCM: {}", e))?;
Ok(key)
}
fn decrypt_with_cng(ciphertext: &[u8]) -> Result<Vec<u8>> {
// 1. Open the cryptographic provider
let mut provider = NCRYPT_PROV_HANDLE::default();
unsafe {
NCryptOpenStorageProvider(
&mut provider,
w!("Microsoft Software Key Storage Provider"),
0,
)?;
};
// Don't forget to free the provider
defer!(unsafe {
_ = Cryptography::NCryptFreeObject(provider.into());
});
// 2. Open the key
let mut key = NCRYPT_KEY_HANDLE::default();
unsafe {
NCryptOpenKey(
provider,
&mut key,
w!("Google Chromekey1"),
CERT_KEY_SPEC::default(),
NCRYPT_FLAGS::default(),
)?;
};
// Don't forget to free the key
defer!(unsafe {
_ = Cryptography::NCryptFreeObject(key.into());
});
// 3. Decrypt the data (assume the plaintext is not larger than the ciphertext)
let mut plaintext = vec![0; ciphertext.len()];
let mut plaintext_len = 0;
unsafe {
Cryptography::NCryptDecrypt(
key,
ciphertext.into(),
None,
Some(&mut plaintext),
&mut plaintext_len,
NCRYPT_SILENT_FLAG,
)?;
};
// In case the plaintext is smaller than the ciphertext
plaintext.truncate(plaintext_len as usize);
Ok(plaintext)
}

View File

@@ -0,0 +1,139 @@
use anyhow::{anyhow, Result};
use sysinfo::System;
use tracing::debug;
use windows::{
core::BOOL,
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
Win32::{
Foundation::{CloseHandle, HANDLE, NTSTATUS, STATUS_SUCCESS},
Security::{
self, DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
TOKEN_QUERY,
},
System::Threading::{OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION},
},
};
use super::config::SYSTEM_PROCESS_NAMES;
#[link(name = "ntdll")]
unsafe extern "system" {
unsafe fn RtlAdjustPrivilege(
privilege: i32,
enable: BOOL,
current_thread: BOOL,
previous_value: *mut BOOL,
) -> NTSTATUS;
}
pub(crate) fn start_impersonating() -> Result<HANDLE> {
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
enable_debug_privilege()?;
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
// Impersonate the SYSTEM process
unsafe {
ImpersonateLoggedOnUser(token)?;
};
debug!("Impersonating system process '{}' (PID: {})", name, pid);
Ok(token)
}
pub(crate) fn stop_impersonating(token: HANDLE) -> Result<()> {
unsafe {
RevertToSelf()?;
CloseHandle(token)?;
};
Ok(())
}
fn find_system_process_with_token(
pids: Vec<(u32, &'static str)>,
) -> Result<(HANDLE, u32, &'static str)> {
for (pid, name) in pids {
match get_system_token_from_pid(pid) {
Err(_) => {
debug!(
"Failed to open process handle '{}' (PID: {}), skipping",
name, pid
);
continue;
}
Ok(system_handle) => {
return Ok((system_handle, pid, name));
}
}
}
Err(anyhow!("Failed to get system token from any process"))
}
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
let handle = get_process_handle(pid)?;
let token = get_system_token(handle)?;
unsafe {
CloseHandle(handle)?;
};
Ok(token)
}
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
let token_handle = unsafe {
let mut token_handle = HANDLE::default();
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
token_handle
};
let duplicate_token = unsafe {
let mut duplicate_token = HANDLE::default();
DuplicateToken(
token_handle,
Security::SECURITY_IMPERSONATION_LEVEL(2),
&mut duplicate_token,
)?;
CloseHandle(token_handle)?;
duplicate_token
};
Ok(duplicate_token)
}
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
let sys = System::new_all();
SYSTEM_PROCESS_NAMES
.iter()
.flat_map(|&name| {
sys.processes_by_exact_name(name.as_ref())
.map(move |process| (process.pid().as_u32(), name))
})
.collect()
}
fn get_process_handle(pid: u32) -> Result<HANDLE> {
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
Ok(hprocess)
}
fn enable_debug_privilege() -> Result<()> {
let mut previous_value = BOOL(0);
let status = unsafe {
debug!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
};
match status {
STATUS_SUCCESS => {
debug!(
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
previous_value.as_bool()
);
Ok(())
}
_ => {
debug!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
Err(anyhow!("Failed to adjust privilege"))
}
}
}

View File

@@ -0,0 +1,29 @@
use tracing::{error, level_filters::LevelFilter};
use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
use super::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME};
pub(crate) fn init_logging() {
if ENABLE_DEVELOPER_LOGGING {
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
match std::fs::File::create(LOG_FILENAME) {
Ok(file) => {
let file_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy();
let file_layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.with_filter(file_filter);
tracing_subscriber::registry().with(file_layer).init();
}
Err(error) => {
error!(%error, ?LOG_FILENAME, "Could not create log file.");
}
}
}
}

View File

@@ -0,0 +1,228 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use scopeguard::defer;
use std::{
ffi::OsString,
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
path::PathBuf,
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
time,
};
use tracing::{debug, error};
use windows::Win32::{
Foundation::{CloseHandle, ERROR_PIPE_BUSY, HANDLE},
System::{
Pipes::GetNamedPipeServerProcessId,
Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
},
},
UI::Shell::IsUserAnAdmin,
};
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
use super::{
config::ENABLE_SERVER_SIGNATURE_VALIDATION,
crypto::{
decode_abe_key_blob, decode_base64, decrypt_with_dpapi_as_system,
decrypt_with_dpapi_as_user, encode_base64,
},
log::init_logging,
};
#[derive(Parser)]
#[command(name = "bitwarden_chromium_import_helper")]
#[command(about = "Admin tool for ABE service management")]
struct Args {
#[arg(long, help = "Base64 encoded encrypted data string")]
encrypted: String,
}
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
let max_attempts = 5;
for _ in 0..max_attempts {
match ClientOptions::new().open(pipe_name) {
Ok(client) => {
debug!("Successfully connected to the pipe!");
return Ok(client);
}
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
debug!("Pipe is busy, retrying in 50ms...");
}
Err(e) => {
debug!("Failed to connect to pipe: {}", &e);
return Err(e.into());
}
}
time::sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"Failed to connect to pipe after {} attempts",
max_attempts
))
}
async fn send_message_with_client(client: &mut NamedPipeClient, message: &str) -> Result<String> {
client.write_all(message.as_bytes()).await?;
// Try to receive a response for this message
let mut buffer = vec![0u8; 64 * 1024];
match client.read(&mut buffer).await {
Ok(0) => Err(anyhow!(
"Server closed the connection (0 bytes read) on message"
)),
Ok(bytes_received) => {
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
Ok(response.to_string())
}
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
}
}
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
let handle = HANDLE(client.as_raw_handle() as _);
let mut pid: u32 = 0;
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
Ok(pid)
}
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
debug!("Resolving process executable path for PID {}", pid);
// Open the process handle
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
debug!("Opened process handle for PID {}", pid);
// Close when no longer needed
defer! {
debug!("Closing process handle for PID {}", pid);
unsafe {
_ = CloseHandle(hprocess);
}
};
let mut exe_name = vec![0u16; 32 * 1024];
let mut exe_name_length = exe_name.len() as u32;
unsafe {
QueryFullProcessImageNameW(
hprocess,
PROCESS_NAME_WIN32,
windows::core::PWSTR(exe_name.as_mut_ptr()),
&mut exe_name_length,
)
}?;
debug!(
"QueryFullProcessImageNameW returned {} bytes",
exe_name_length
);
exe_name.truncate(exe_name_length as usize);
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
}
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
_ = send_to_user(client, &format!("!{}", error_message)).await
}
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
let _ = send_message_with_client(client, message).await?;
Ok(())
}
fn is_admin() -> bool {
unsafe { IsUserAnAdmin().as_bool() }
}
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
let client = open_pipe_client(pipe_name).await?;
if ENABLE_SERVER_SIGNATURE_VALIDATION {
let server_pid = get_named_pipe_server_pid(&client)?;
debug!("Connected to pipe server PID {}", server_pid);
// Validate the server end process signature
let exe_path = resolve_process_executable_path(server_pid)?;
debug!("Pipe server executable path: {}", exe_path.display());
if !verify_signature(&exe_path)? {
return Err(anyhow!("Pipe server signature is not valid"));
}
debug!("Pipe server signature verified for PID {}", server_pid);
}
Ok(client)
}
fn run() -> Result<String> {
debug!("Starting bitwarden_chromium_import_helper.exe");
let args = Args::try_parse()?;
if !is_admin() {
return Err(anyhow!("Expected to run with admin privileges"));
}
debug!("Running as ADMINISTRATOR");
let encrypted = decode_base64(&args.encrypted)?;
debug!(
"Decoded encrypted data [{}] {:?}",
encrypted.len(),
encrypted
);
let system_decrypted = decrypt_with_dpapi_as_system(&encrypted)?;
debug!(
"Decrypted data with DPAPI as SYSTEM {} {:?}",
system_decrypted.len(),
system_decrypted
);
let user_decrypted = decrypt_with_dpapi_as_user(&system_decrypted, false)?;
debug!(
"Decrypted data with DPAPI as USER {} {:?}",
user_decrypted.len(),
user_decrypted
);
let key = decode_abe_key_blob(&user_decrypted)?;
debug!("Decoded ABE key blob {} {:?}", key.len(), key);
Ok(encode_base64(&key))
}
pub(crate) async fn main() {
init_logging();
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
Ok(client) => client,
Err(e) => {
error!(
"Failed to open pipe {} to send result/error: {}",
ADMIN_TO_USER_PIPE_NAME, e
);
return;
}
};
match run() {
Ok(system_decrypted_base64) => {
debug!("Sending response back to user");
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
}
Err(e) => {
debug!("Error: {}", e);
send_error_to_user(&mut client, &format!("{}", e)).await;
}
}
}

View File

@@ -0,0 +1,7 @@
mod config;
mod crypto;
mod impersonate;
mod log;
mod main;
pub(crate) use main::main;

View File

@@ -23,20 +23,10 @@ sha1 = "=0.10.6"
security-framework = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
aes-gcm = "=0.10.3"
aes-gcm = { workspace = true }
base64 = { workspace = true }
chacha20poly1305 = { workspace = true }
windows = { workspace = true, features = [
"Wdk_System_SystemServices",
"Win32_Security_Cryptography",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_ProcessStatus",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }

View File

@@ -4,7 +4,7 @@ A rust library that allows you to directly import credentials from Chromium-base
## Windows ABE Architecture
On Windows chrome has additional protection measurements which needs to be circumvented in order to
On Windows Chrome has additional protection measurements which needs to be circumvented in order to
get access to the passwords.
### Overview
@@ -25,7 +25,9 @@ encryption scheme for some local profiles.
The general idea of this encryption scheme is as follows:
1. Chrome generates a unique random encryption key.
2. This key is first encrypted at the **user level** with a fixed key.
2. This key is first encrypted at the **user level** with a fixed key for v1/v2 of ABE. For ABE v3 a more complicated
scheme is used that encrypts the key with a combination of a fixed key and a randomly generated key at the **system
level** via Windows CNG API.
3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**.
4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**.
@@ -37,7 +39,7 @@ The following sections describe how the key is decrypted at each level.
This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and
`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`.
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `platform/windows/mod.rs`.
This function takes two arguments:
@@ -75,10 +77,26 @@ With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a
> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.**
The received encryption key can now be decrypted using DPAPI at the system level.
The received encryption key can now be decrypted using DPAPI at the **system level**.
The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to
the pipe and writes the result.
Next, the impersonation is stopped and the feshly decrypted key is decrypted at the **user level** with DPAPI one more
time.
At this point, for browsers not using the custom encryption/obfuscation layer like unbranded Chromium, the twice
decrypted key is the actual encryption key that could be used to decrypt the stored passwords.
For other browsers like Google Chrome, some additional processing is required. The decrypted key is actually a blob of structured data that could take multiple forms:
1. exactly 32 bytes: plain key, nothing to be done more in this case
2. blob starts with 0x01: the key is encrypted with a fixed AES key found in Google Chrome binary, a random IV is stored
in the blob as well
3. blob starts with 0x02: the key is encrypted with a fixed ChaCha20 key found in Google Chrome binary, a random IV is
stored in the blob as well
4. blob starts with 0x03: the blob contains a random key, encrypted with CNG API with a random key stored in the
**system keychain** under the name `Google Chromekey1`. After that key is decryped (under **system level** impersonation again), the key is xor'ed with a fixed key from the Chrome binary and the it is used to decrypt the key from the last DPAPI decryption stage.
The decrypted key is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to the
pipe and writes the result.
The response can indicate success or failure:
@@ -92,17 +110,8 @@ Finally, `bitwarden_chromium_import_helper.exe` exits.
### 3. Back to the Client Library
The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at
the user level. At this point it has been decrypted only once—at the system level.
Next, the string is decrypted at the **user level** with DPAPI.
Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe`
from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step
uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details.
After these steps, the master key is available and can be used to decrypt the password information stored in the
browsers local database.
The decrypted Base64-encoded key is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at the
user level. The key is used to decrypt the stored passwords and notes.
### TL;DR Steps
@@ -120,13 +129,12 @@ browsers local database.
2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests).
3. Impersonate a system process such as `services.exe` or `winlogon.exe`.
4. Decrypt the key using DPAPI at the **SYSTEM** level.
5. Decrypt it again with DPAPI at the **USER** level.
6. (For Chrome only) Decrypt again with the hard-coded key, possibly at the **system level** again (see above).
5. Send the result or error back via the named pipe.
6. Exit.
3. **Back on the client side:**
1. Receive the encryption key.
1. Receive the master key.
2. Shutdown the pipe server.
3. Decrypt it with DPAPI at the **USER** level.
4. (For Chrome only) Decrypt again with the hard-coded key.
5. Obtain the fully decrypted master key.
6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.
3. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.

View File

@@ -2,7 +2,6 @@ use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use chacha20poly1305::ChaCha20Poly1305;
use std::path::{Path, PathBuf};
use windows::Win32::{
Foundation::{LocalFree, HLOCAL},
@@ -208,119 +207,8 @@ impl WindowsCryptoService {
));
}
let key_bytes = BASE64_STANDARD.decode(&key_base64)?;
let key = unprotect_data_win(&key_bytes)?;
Self::decode_abe_key_blob(key.as_slice())
}
fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize;
// Ignore the header
let content_len_offset = 4 + header_len;
let content_len =
u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?)
as usize;
if content_len < 1 {
return Err(anyhow!(
"Corrupted ABE key blob: content length is less than 1"
));
}
let content_offset = content_len_offset + 4;
let content = &blob_data[content_offset..content_offset + content_len];
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
if content_len == 32 {
return Ok(content.to_vec());
}
let version = content[0];
let key_blob = &content[1..];
match version {
// Google Chrome v1 key encrypted with a hardcoded AES key
1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob),
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob),
// Google Chrome v3 key encrypted with CNG APIs
3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob),
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
}
}
// TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let iv: [u8; 12] = blob[0..12].try_into()?;
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
const GOOGLE_AES_KEY: &[u8] = &[
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93,
0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D,
0xA0, 0x28, 0x47, 0x87,
];
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
let cipher = Aes256Gcm::new(aes_key);
let decrypted = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
let cipher = ChaCha20Poly1305::new(chacha20_key);
const GOOGLE_CHACHA20_KEY: &[u8] = &[
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25,
0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D,
0x08, 0x72, 0x96, 0x60,
];
let iv: [u8; 12] = blob[0..12].try_into()?;
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
let decrypted = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 92 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
blob.len()
));
}
let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
let _iv: [u8; 12] = blob[32..32 + 12].try_into()?;
let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
// TODO: Decrypt the AES key using CNG APIs
// TODO: Implement this in the future once we run into a browser that uses this scheme
// There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested.
Err(anyhow!("Google ABE CNG flavor is not supported yet"))
let key = BASE64_STANDARD.decode(&key_base64)?;
Ok(key)
}
}

View File

@@ -21,11 +21,13 @@ platform :mac do
.split('.')
.map(&:strip)
.reject(&:empty?)
.map { |item| "• #{item}" }
.map { |item| "• #{item.gsub(/\A(?:•|\u2022)\s*/, '')}" }
.join("\n")
UI.message("Original changelog: #{changelog[0,100]}#{changelog.length > 100 ? '...' : ''}")
UI.message("Formatted changelog: #{formatted_changelog[0,100]}#{formatted_changelog.length > 100 ? '...' : ''}")
UI.message("Original changelog: ")
UI.message("#{changelog}")
UI.message("Formatted changelog: ")
UI.message("#{formatted_changelog}")
# Create release notes directories and files for all locales
APP_CONFIG[:locales].each do |locale|

View File

@@ -32,6 +32,7 @@ import { ModalService } from "@bitwarden/angular/services/modal.service";
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
import {
DESKTOP_SSO_CALLBACK,
LockService,
LogoutReason,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
@@ -195,6 +196,7 @@ export class AppComponent implements OnInit, OnDestroy {
private pinService: PinServiceAbstraction,
private readonly tokenService: TokenService,
private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy,
private readonly lockService: LockService,
) {
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
@@ -245,7 +247,7 @@ export class AppComponent implements OnInit, OnDestroy {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.updateAppMenu();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "authBlocked":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@@ -258,21 +260,10 @@ export class AppComponent implements OnInit, OnDestroy {
this.loading = false;
break;
case "lockVault":
await this.vaultTimeoutService.lock(message.userId);
await this.lockService.lock(message.userId);
break;
case "lockAllVaults": {
const currentUser = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a.id)),
);
const accounts = await firstValueFrom(this.accountService.accounts$);
await this.vaultTimeoutService.lock(currentUser);
for (const account of Object.keys(accounts)) {
if (account === currentUser) {
continue;
}
await this.vaultTimeoutService.lock(account);
}
await this.lockService.lockAll();
break;
}
case "locked":
@@ -286,12 +277,12 @@ export class AppComponent implements OnInit, OnDestroy {
}
await this.updateAppMenu();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "startProcessReload":
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.processReloadService.startProcessReload(this.authService);
this.processReloadService.startProcessReload();
break;
case "cancelProcessReload":
this.processReloadService.cancelProcessReload();
@@ -736,8 +727,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
}
await this.updateAppMenu();
// This must come last otherwise the logout will prematurely trigger
// a process reload before all the state service user data can be cleaned up
this.authService.logOut(async () => {}, userBeingLoggedOut);
@@ -814,11 +803,9 @@ export class AppComponent implements OnInit, OnDestroy {
}
const options = await this.getVaultTimeoutOptions(userId);
if (options[0] === timeout) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
options[1] === "logOut"
? this.logOut("vaultTimeout", userId as UserId)
: await this.vaultTimeoutService.lock(userId);
? await this.logOut("vaultTimeout", userId as UserId)
: await this.lockService.lock(userId as UserId);
}
}
}

View File

@@ -262,6 +262,7 @@ const safeProviders: SafeProvider[] = [
BiometricStateService,
AccountServiceAbstraction,
LogService,
AuthServiceAbstraction,
],
}),
safeProvider({

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setPinForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "unlockWithPin" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -1,6 +1,6 @@
<form [bitSubmit]="submit" [formGroup]="setShortcutForm">
<bit-dialog>
<div class="tw-font-semibold" bitDialogTitle>
<div class="tw-font-medium" bitDialogTitle>
{{ "typeShortcut" | i18n }}
</div>
<div bitDialogContent>

View File

@@ -69,6 +69,9 @@
}
}
},
"noEditPermissions": {
"message": "You don't have permission to edit this item"
},
"welcomeBack": {
"message": "Welcome back"
},

View File

@@ -124,7 +124,7 @@
>
<i class="bwi bwi-2x bwi-business tw-text-primary-600"></i>
<p class="tw-font-bold tw-mt-2">
<p class="tw-font-medium tw-mt-2">
{{ "upgradeEventLogTitleMessage" | i18n }}
</p>
<p>

View File

@@ -34,7 +34,7 @@
(change)="toggleAllVisible($event)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>
@@ -64,7 +64,7 @@
<td bitCell (click)="check(g)" class="tw-cursor-pointer">
<input type="checkbox" bitCheckbox [(ngModel)]="g.checked" />
</td>
<td bitCell class="tw-cursor-pointer tw-font-bold" (click)="edit(g)">
<td bitCell class="tw-cursor-pointer tw-font-medium" (click)="edit(g)">
<button type="button" bitLink>
{{ g.details.name }}
</button>

View File

@@ -94,7 +94,7 @@
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">{{
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">{{
"all" | i18n
}}</label>
</th>

View File

@@ -7,7 +7,7 @@
<ul class="tw-mb-6 tw-pl-6">
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmAcceptSecurityRiskTitle" | i18n }}
</span>
{{ "autoConfirmAcceptSecurityRiskDescription" | i18n }}
@@ -19,11 +19,11 @@
<li>
@if (singleOrgEnabled$ | async) {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgExemption" | i18n }}
</span>
} @else {
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmSingleOrgRequired" | i18n }}
</span>
}
@@ -31,7 +31,7 @@
</li>
<li>
<span class="tw-font-bold">
<span class="tw-font-medium">
{{ "autoConfirmNoEmergencyAccess" | i18n }}
</span>
{{ "autoConfirmNoEmergencyAccessDescription" | i18n }}

View File

@@ -100,7 +100,7 @@
<ng-template #readOnlyPerm>
<div
*ngIf="item.readonly || disabled"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-bold tw-text-muted"
class="tw-max-w-40 tw-overflow-hidden tw-overflow-ellipsis tw-whitespace-nowrap tw-font-medium tw-text-muted"
[title]="permissionLabelId(item.readonlyPermission) | i18n"
>
{{ permissionLabelId(item.readonlyPermission) | i18n }}

View File

@@ -8,6 +8,7 @@ import { Subject, filter, firstValueFrom, map, timeout } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { DeviceTrustToastService } from "@bitwarden/angular/auth/services/device-trust-toast.service.abstraction";
import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
import { LockService } from "@bitwarden/auth/common";
import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service";
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -16,7 +17,6 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -58,8 +58,8 @@ export class AppComponent implements OnDestroy, OnInit {
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private ngZone: NgZone,
private vaultTimeoutService: VaultTimeoutService,
private keyService: KeyService,
private lockService: LockService,
private collectionService: CollectionService,
private searchService: SearchService,
private serverNotificationsService: ServerNotificationsService,
@@ -113,11 +113,13 @@ export class AppComponent implements OnDestroy, OnInit {
// note: the message.logoutReason isn't consumed anymore because of the process reload clearing any toasts.
await this.logOut(message.redirect);
break;
case "lockVault":
await this.vaultTimeoutService.lock();
case "lockVault": {
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
await this.lockService.lock(userId);
break;
}
case "locked":
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
break;
case "lockedUrl":
break;
@@ -267,7 +269,7 @@ export class AppComponent implements OnDestroy, OnInit {
await this.router.navigate(["/"]);
}
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
// Normally we would need to reset the loading state to false or remove the layout_frontend
// class from the body here, but the process reload completely reloads the app so

View File

@@ -8,6 +8,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -16,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId, EmergencyAccessId } from "@bitwarden/common/types/guid";
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums";
@@ -68,6 +70,12 @@ describe("EmergencyViewDialogComponent", () => {
useValue: { environment$: of({ getIconsUrl: () => "https://icons.example.com" }) },
},
{ provide: DomainSettingsService, useValue: { showFavicons$: of(true) } },
{ provide: CipherRiskService, useValue: mock<CipherRiskService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
],
})
.overrideComponent(EmergencyViewDialogComponent, {
@@ -78,7 +86,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: ChangeLoginPasswordService,
},
{ provide: ConfigService, useValue: ConfigService },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},
@@ -89,7 +96,6 @@ describe("EmergencyViewDialogComponent", () => {
provide: ChangeLoginPasswordService,
useValue: mock<ChangeLoginPasswordService>(),
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: CipherService, useValue: mock<CipherService>() },
],
},

View File

@@ -17,10 +17,10 @@
<ul class="bwi-ul">
<li *ngFor="let k of keys; let i = index" #removeKeyBtn [appApiAction]="k.removePromise">
<i class="bwi bwi-li bwi-key"></i>
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="!k.configured || !k.name" bitTypography="body1" class="tw-font-medium">
{{ "webAuthnkeyX" | i18n: (i + 1).toString() }}
</span>
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-bold">
<span *ngIf="k.configured && k.name" bitTypography="body1" class="tw-font-medium">
{{ k.name }}
</span>
<ng-container *ngIf="k.configured && !$any(removeKeyBtn).loading">

View File

@@ -45,7 +45,7 @@
</div>
</div>
</div>
<p bitTypography="body1" class="tw-font-bold tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<p bitTypography="body1" class="tw-font-medium tw-mb-2">{{ "nfcSupport" | i18n }}</p>
<bit-form-control [disableMargin]="true">
<bit-label>{{ "twoFactorYubikeySupportsNfc" | i18n }}</bit-label>
<input bitCheckbox type="checkbox" formControlName="anyKeyHasNfc" />

View File

@@ -53,7 +53,7 @@
<div bit-item-content class="tw-px-4">
<h3 class="tw-mb-0">
<div
class="tw-font-semibold tw-text-base"
class="tw-font-medium tw-text-base"
[style]="p.enabled || p.premium ? 'display:inline-block' : ''"
>
{{ p.name }}

View File

@@ -34,7 +34,7 @@
<table *ngIf="hasCredentials" class="tw-mb-5">
<tr *ngFor="let credential of credentials">
<td class="tw-p-2 tw-pl-0 tw-font-semibold">{{ credential.name }}</td>
<td class="tw-p-2 tw-pl-0 tw-font-medium">{{ credential.name }}</td>
<td class="tw-p-2 tw-pr-10 tw-text-left">
<ng-container *ngIf="credential.prfStatus === WebauthnLoginCredentialPrfStatus.Enabled">
<i class="bwi bwi-lock-encrypted"></i>

View File

@@ -2,15 +2,15 @@ import { inject, NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { map } from "rxjs";
import { componentRouteSwap } from "@bitwarden/angular/utils/component-route-swap";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AccountPaymentDetailsComponent } from "@bitwarden/web-vault/app/billing/individual/payment-details/account-payment-details.component";
import { SelfHostedPremiumComponent } from "@bitwarden/web-vault/app/billing/individual/premium/self-hosted-premium.component";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { PremiumVNextComponent } from "./premium/premium-vnext.component";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumVNextComponent } from "./premium/cloud-hosted-premium-vnext.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -26,22 +26,55 @@ const routes: Routes = [
component: UserSubscriptionComponent,
data: { titleId: "premiumMembership" },
},
...componentRouteSwap(
PremiumComponent,
PremiumVNextComponent,
() => {
const configService = inject(ConfigService);
const platformUtilsService = inject(PlatformUtilsService);
/**
* Three-Route Matching Strategy for /premium:
*
* Routes are evaluated in order using canMatch guards. The first route that matches will be selected.
*
* 1. Self-Hosted Environment → SelfHostedPremiumComponent
* - Matches when platformUtilsService.isSelfHost() === true
*
* 2. Cloud-Hosted + Feature Flag Enabled → CloudHostedPremiumVNextComponent
* - Only evaluated if Route 1 doesn't match (not self-hosted)
* - Matches when PM24033PremiumUpgradeNewDesign feature flag === true
*
* 3. Cloud-Hosted + Feature Flag Disabled → CloudHostedPremiumComponent (Fallback)
* - No canMatch guard, so this always matches as the fallback route
* - Used when neither Route 1 nor Route 2 match
*/
// Route 1: Self-Hosted -> SelfHostedPremiumComponent
{
path: "premium",
component: SelfHostedPremiumComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const platformUtilsService = inject(PlatformUtilsService);
return platformUtilsService.isSelfHost();
},
],
},
// Route 2: Cloud Hosted + FF -> CloudHostedPremiumVNextComponent
{
path: "premium",
component: CloudHostedPremiumVNextComponent,
data: { titleId: "goPremium" },
canMatch: [
() => {
const configService = inject(ConfigService);
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true && !platformUtilsService.isSelfHost()));
},
{
data: { titleId: "goPremium" },
path: "premium",
},
),
return configService
.getFeatureFlag$(FeatureFlag.PM24033PremiumUpgradeNewDesign)
.pipe(map((flagValue) => flagValue === true));
},
],
},
// Route 3: Cloud Hosted + FF Disabled -> CloudHostedPremiumComponent (Fallback)
{
path: "premium",
component: CloudHostedPremiumComponent,
data: { titleId: "goPremium" },
},
{
path: "payment-details",
component: AccountPaymentDetailsComponent,

View File

@@ -11,7 +11,7 @@ import { BillingSharedModule } from "../shared";
import { BillingHistoryViewComponent } from "./billing-history-view.component";
import { IndividualBillingRoutingModule } from "./individual-billing-routing.module";
import { PremiumComponent } from "./premium/premium.component";
import { CloudHostedPremiumComponent } from "./premium/cloud-hosted-premium.component";
import { SubscriptionComponent } from "./subscription.component";
import { UserSubscriptionComponent } from "./user-subscription.component";
@@ -28,7 +28,7 @@ import { UserSubscriptionComponent } from "./user-subscription.component";
SubscriptionComponent,
BillingHistoryViewComponent,
UserSubscriptionComponent,
PremiumComponent,
CloudHostedPremiumComponent,
],
})
export class IndividualBillingModule {}

View File

@@ -7,7 +7,7 @@
</span>
</div>
<h2 *ngIf="!isSelfHost" class="tw-mt-2 tw-text-4xl">
<h2 class="tw-mt-2 tw-text-4xl">
{{ "upgradeCompleteSecurity" | i18n }}
</h2>
<p class="tw-text-muted tw-mb-6 tw-mt-4">

View File

@@ -21,7 +21,6 @@ import {
PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import {
BadgeModule,
@@ -52,7 +51,7 @@ const RouteParamValues = {
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./premium-vnext.component.html",
templateUrl: "./cloud-hosted-premium-vnext.component.html",
standalone: true,
imports: [
CommonModule,
@@ -64,7 +63,7 @@ const RouteParamValues = {
PricingCardComponent,
],
})
export class PremiumVNextComponent {
export class CloudHostedPremiumVNextComponent {
protected hasPremiumFromAnyOrganization$: Observable<boolean>;
protected hasPremiumPersonally$: Observable<boolean>;
protected shouldShowNewDesign$: Observable<boolean>;
@@ -81,22 +80,18 @@ export class PremiumVNextComponent {
features: string[];
}>;
protected subscriber!: BitwardenSubscriber;
protected isSelfHost = false;
private destroyRef = inject(DestroyRef);
constructor(
private accountService: AccountService,
private apiService: ApiService,
private dialogService: DialogService,
private platformUtilsService: PlatformUtilsService,
private syncService: SyncService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
private router: Router,
private activatedRoute: ActivatedRoute,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
@@ -187,10 +182,12 @@ export class PremiumVNextComponent {
this.shouldShowUpgradeDialogOnInit$
.pipe(
switchMap(async (shouldShowUpgradeDialogOnInit) => {
switchMap((shouldShowUpgradeDialogOnInit) => {
if (shouldShowUpgradeDialogOnInit) {
from(this.openUpgradeDialog("Premium"));
return from(this.openUpgradeDialog("Premium"));
}
// Return an Observable that completes immediately when dialog should not be shown
return of(void 0);
}),
takeUntilDestroyed(this.destroyRef),
)

View File

@@ -10,7 +10,7 @@
} @else {
<bit-container>
<bit-section>
<h2 *ngIf="!isSelfHost" bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<h2 bitTypography="h2">{{ "goPremium" | i18n }}</h2>
<bit-callout
type="info"
*ngIf="hasPremiumFromAnyOrganization$ | async"
@@ -51,7 +51,7 @@
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<p bitTypography="body1" [ngClass]="{ 'tw-mb-0': !isSelfHost }">
<p bitTypography="body1" class="tw-mb-0">
{{
"premiumPriceWithFamilyPlan"
| i18n: (premiumPrice$ | async | currency: "$") : familyPlanMaxUserCount
@@ -65,24 +65,9 @@
{{ "bitwardenFamiliesPlan" | i18n }}
</a>
</p>
<a
bitButton
href="{{ premiumURL }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
*ngIf="isSelfHost"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section *ngIf="isSelfHost">
<individual-self-hosting-license-uploader
(onLicenseFileUploaded)="onLicenseFileSelectedChanged()"
/>
</bit-section>
<form *ngIf="!isSelfHost" [formGroup]="formGroup" [bitSubmit]="submitPayment">
<form [formGroup]="formGroup" [bitSubmit]="submitPayment">
<bit-section>
<h2 bitTypography="h2">{{ "addons" | i18n }}</h2>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">

View File

@@ -27,7 +27,6 @@ import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/ser
import { PersonalSubscriptionPricingTierIds } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync";
import { ToastService } from "@bitwarden/components";
import { SubscriberBillingClient, TaxClient } from "@bitwarden/web-vault/app/billing/clients";
@@ -45,11 +44,11 @@ import { mapAccountToSubscriber } from "@bitwarden/web-vault/app/billing/types";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./premium.component.html",
templateUrl: "./cloud-hosted-premium.component.html",
standalone: false,
providers: [SubscriberBillingClient, TaxClient],
})
export class PremiumComponent {
export class CloudHostedPremiumComponent {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(EnterPaymentMethodComponent) enterPaymentMethodComponent!: EnterPaymentMethodComponent;
@@ -121,7 +120,6 @@ export class PremiumComponent {
);
protected cloudWebVaultURL: string;
protected isSelfHost = false;
protected readonly familyPlanMaxUserCount = 6;
constructor(
@@ -130,7 +128,6 @@ export class PremiumComponent {
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private syncService: SyncService,
private toastService: ToastService,
@@ -139,8 +136,6 @@ export class PremiumComponent {
private taxClient: TaxClient,
private subscriptionPricingService: DefaultSubscriptionPricingService,
) {
this.isSelfHost = this.platformUtilsService.isSelfHost();
this.hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id),
@@ -231,7 +226,10 @@ export class PremiumComponent {
const formData = new FormData();
formData.append("paymentMethodType", paymentMethodType.toString());
formData.append("paymentToken", paymentToken);
formData.append("additionalStorageGb", this.formGroup.value.additionalStorage.toString());
formData.append(
"additionalStorageGb",
(this.formGroup.value.additionalStorage ?? 0).toString(),
);
formData.append("country", this.formGroup.value.billingAddress.country);
formData.append("postalCode", this.formGroup.value.billingAddress.postalCode);
@@ -239,12 +237,4 @@ export class PremiumComponent {
await this.finalizeUpgrade();
await this.postFinalizeUpgrade();
};
protected get premiumURL(): string {
return `${this.cloudWebVaultURL}/#/settings/subscription/premium`;
}
protected async onLicenseFileSelectedChanged(): Promise<void> {
await this.postFinalizeUpgrade();
}
}

View File

@@ -0,0 +1,49 @@
<bit-container>
<bit-section>
<bit-callout type="success">
<p>{{ "premiumUpgradeUnlockFeatures" | i18n }}</p>
<ul class="bwi-ul">
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpStorage" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTwoStepOptions" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpEmergency" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpReports" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpTotp" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpSupport" | i18n }}
</li>
<li>
<i class="bwi bwi-check tw-text-success bwi-li" aria-hidden="true"></i>
{{ "premiumSignUpFuture" | i18n }}
</li>
</ul>
<a
bitButton
href="{{ cloudPremiumPageUrl$ | async }}"
target="_blank"
rel="noreferrer"
buttonType="secondary"
>
{{ "purchasePremium" | i18n }}
</a>
</bit-callout>
</bit-section>
<bit-section>
<individual-self-hosting-license-uploader (onLicenseFileUploaded)="onLicenseFileUploaded()" />
</bit-section>
</bit-container>

View File

@@ -0,0 +1,79 @@
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, Router } from "@angular/router";
import { combineLatest, map, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ToastService } from "@bitwarden/components";
import { BillingSharedModule } from "@bitwarden/web-vault/app/billing/shared";
import { SharedModule } from "@bitwarden/web-vault/app/shared";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "./self-hosted-premium.component.html",
imports: [SharedModule, BillingSharedModule],
})
export class SelfHostedPremiumComponent {
cloudPremiumPageUrl$ = this.environmentService.cloudWebVaultUrl$.pipe(
map((url) => `${url}/#/settings/subscription/premium`),
);
hasPremiumFromAnyOrganization$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumFromAnyOrganization$(account.id)
: of(false),
),
);
hasPremiumPersonally$ = this.accountService.activeAccount$.pipe(
switchMap((account) =>
account
? this.billingAccountProfileStateService.hasPremiumPersonally$(account.id)
: of(false),
),
);
onLicenseFileUploaded = async () => {
this.toastService.showToast({
variant: "success",
title: "",
message: this.i18nService.t("premiumUpdated"),
});
await this.navigateToSubscription();
};
constructor(
private accountService: AccountService,
private activatedRoute: ActivatedRoute,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private environmentService: EnvironmentService,
private i18nService: I18nService,
private router: Router,
private toastService: ToastService,
) {
combineLatest([this.hasPremiumFromAnyOrganization$, this.hasPremiumPersonally$])
.pipe(
takeUntilDestroyed(),
switchMap(([hasPremiumFromAnyOrganization, hasPremiumPersonally]) => {
if (hasPremiumFromAnyOrganization) {
return this.navigateToVault();
}
if (hasPremiumPersonally) {
return this.navigateToSubscription();
}
return of(true);
}),
)
.subscribe();
}
navigateToSubscription = () =>
this.router.navigate(["../user-subscription"], { relativeTo: this.activatedRoute });
navigateToVault = () => this.router.navigate(["/vault"]);
}

View File

@@ -7,16 +7,21 @@ import { AccountService, Account } from "@bitwarden/common/auth/abstractions/acc
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { DialogRef, DialogService } from "@bitwarden/components";
import { StateProvider } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
import { UnifiedUpgradePromptService } from "./unified-upgrade-prompt.service";
import {
UnifiedUpgradePromptService,
PREMIUM_MODAL_DISMISSED_KEY,
} from "./unified-upgrade-prompt.service";
describe("UnifiedUpgradePromptService", () => {
let sut: UnifiedUpgradePromptService;
@@ -29,6 +34,8 @@ describe("UnifiedUpgradePromptService", () => {
const mockOrganizationService = mock<OrganizationService>();
const mockDialogOpen = jest.spyOn(UnifiedUpgradeDialogComponent, "open");
const mockPlatformUtilsService = mock<PlatformUtilsService>();
const mockStateProvider = mock<StateProvider>();
const mockLogService = mock<LogService>();
/**
* Creates a mock DialogRef that implements the required properties for testing
@@ -59,6 +66,8 @@ describe("UnifiedUpgradePromptService", () => {
mockDialogService,
mockOrganizationService,
mockPlatformUtilsService,
mockStateProvider,
mockLogService,
);
}
@@ -72,6 +81,7 @@ describe("UnifiedUpgradePromptService", () => {
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockStateProvider.getUserState$.mockReturnValue(of(false));
setupTestService();
});
@@ -82,6 +92,7 @@ describe("UnifiedUpgradePromptService", () => {
describe("displayUpgradePromptConditionally", () => {
beforeEach(() => {
accountSubject.next(mockAccount); // Reset account to mockAccount
mockAccountService.activeAccount$ = accountSubject.asObservable();
mockDialogOpen.mockReset();
mockReset(mockDialogService);
@@ -90,11 +101,16 @@ describe("UnifiedUpgradePromptService", () => {
mockReset(mockVaultProfileService);
mockReset(mockSyncService);
mockReset(mockOrganizationService);
mockReset(mockStateProvider);
// Mock sync service methods
mockSyncService.fullSync.mockResolvedValue(true);
mockSyncService.lastSync$.mockReturnValue(of(new Date()));
mockReset(mockPlatformUtilsService);
// Default: modal has not been dismissed
mockStateProvider.getUserState$.mockReturnValue(of(false));
mockStateProvider.setUserState.mockResolvedValue(undefined);
});
it("should subscribe to account and feature flag observables when checking display conditions", async () => {
// Arrange
@@ -256,5 +272,71 @@ describe("UnifiedUpgradePromptService", () => {
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should not show dialog when user has previously dismissed the modal", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
mockStateProvider.getUserState$.mockReturnValue(of(true)); // User has dismissed
setupTestService();
// Act
const result = await sut.displayUpgradePromptConditionally();
// Assert
expect(result).toBeNull();
expect(mockDialogOpen).not.toHaveBeenCalled();
});
it("should save dismissal state when user closes the dialog", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.Closed };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).toHaveBeenCalledWith(
PREMIUM_MODAL_DISMISSED_KEY,
true,
mockAccount.id,
);
});
it("should not save dismissal state when user upgrades to premium", async () => {
// Arrange
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
mockBillingService.hasPremiumFromAnySource$.mockReturnValue(of(false));
mockOrganizationService.memberOrganizations$.mockReturnValue(of([]));
const recentDate = new Date();
recentDate.setMinutes(recentDate.getMinutes() - 3); // 3 minutes old
mockVaultProfileService.getProfileCreationDate.mockResolvedValue(recentDate);
mockPlatformUtilsService.isSelfHost.mockReturnValue(false);
const expectedResult = { status: UnifiedUpgradeDialogStatus.UpgradedToPremium };
mockDialogOpenMethod(createMockDialogRef(expectedResult));
setupTestService();
// Act
await sut.displayUpgradePromptConditionally();
// Assert
expect(mockStateProvider.setUserState).not.toHaveBeenCalled();
});
});
});

View File

@@ -8,16 +8,29 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SyncService } from "@bitwarden/common/platform/sync/sync.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DialogRef, DialogService } from "@bitwarden/components";
import { BILLING_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogResult,
UnifiedUpgradeDialogStatus,
} from "../unified-upgrade-dialog/unified-upgrade-dialog.component";
// State key for tracking premium modal dismissal
export const PREMIUM_MODAL_DISMISSED_KEY = new UserKeyDefinition<boolean>(
BILLING_DISK,
"premiumModalDismissed",
{
deserializer: (value: boolean) => value,
clearOn: [],
},
);
@Injectable({
providedIn: "root",
})
@@ -32,6 +45,8 @@ export class UnifiedUpgradePromptService {
private dialogService: DialogService,
private organizationService: OrganizationService,
private platformUtilsService: PlatformUtilsService,
private stateProvider: StateProvider,
private logService: LogService,
) {}
private shouldShowPrompt$: Observable<boolean> = this.accountService.activeAccount$.pipe(
@@ -45,22 +60,36 @@ export class UnifiedUpgradePromptService {
return of(false);
}
const isProfileLessThanFiveMinutesOld = from(
const isProfileLessThanFiveMinutesOld$ = from(
this.isProfileLessThanFiveMinutesOld(account.id),
);
const hasOrganizations = from(this.hasOrganizations(account.id));
const hasOrganizations$ = from(this.hasOrganizations(account.id));
const hasDismissedModal$ = this.hasDismissedModal$(account.id);
return combineLatest([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
isProfileLessThanFiveMinutesOld$,
hasOrganizations$,
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
this.configService.getFeatureFlag$(FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog),
hasDismissedModal$,
]).pipe(
map(([isProfileLessThanFiveMinutesOld, hasOrganizations, hasPremium, isFlagEnabled]) => {
return (
isProfileLessThanFiveMinutesOld && !hasOrganizations && !hasPremium && isFlagEnabled
);
}),
map(
([
isProfileLessThanFiveMinutesOld,
hasOrganizations,
hasPremium,
isFlagEnabled,
hasDismissed,
]) => {
return (
isProfileLessThanFiveMinutesOld &&
!hasOrganizations &&
!hasPremium &&
isFlagEnabled &&
!hasDismissed
);
},
),
);
}),
take(1),
@@ -114,6 +143,17 @@ export class UnifiedUpgradePromptService {
const result = await firstValueFrom(this.unifiedUpgradeDialogRef.closed);
this.unifiedUpgradeDialogRef = null;
// Save dismissal state when the modal is closed without upgrading
if (result?.status === UnifiedUpgradeDialogStatus.Closed) {
try {
await this.stateProvider.setUserState(PREMIUM_MODAL_DISMISSED_KEY, true, account.id);
} catch (error) {
// Log the error but don't block the dialog from closing
// The modal will still close properly even if persistence fails
this.logService.error("Failed to save premium modal dismissal state:", error);
}
}
// Return the result or null if the dialog was dismissed without a result
return result || null;
}
@@ -145,4 +185,15 @@ export class UnifiedUpgradePromptService {
return memberOrganizations.length > 0;
}
/**
* Checks if the user has previously dismissed the premium modal
* @param userId User ID to check
* @returns Observable that emits true if modal was dismissed, false otherwise
*/
private hasDismissedModal$(userId: UserId): Observable<boolean> {
return this.stateProvider
.getUserState$(PREMIUM_MODAL_DISMISSED_KEY, userId)
.pipe(map((dismissed) => dismissed ?? false));
}
}

View File

@@ -80,6 +80,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { IpcService } from "@bitwarden/common/platform/ipc";
// eslint-disable-next-line no-restricted-imports -- Needed for DI
import {
@@ -143,6 +144,7 @@ import { WebEnvironmentService } from "../platform/web-environment.service";
import { WebMigrationRunner } from "../platform/web-migration-runner";
import { WebSdkLoadService } from "../platform/web-sdk-load.service";
import { WebStorageServiceProvider } from "../platform/web-storage-service.provider";
import { WebSystemService } from "../platform/web-system.service";
import { EventService } from "./event.service";
import { InitService } from "./init.service";
@@ -428,6 +430,11 @@ const safeProviders: SafeProvider[] = [
useClass: WebPremiumInterestStateService,
deps: [StateProvider],
}),
safeProvider({
provide: SystemService,
useClass: WebSystemService,
deps: [],
}),
];
@NgModule({

View File

@@ -1,10 +1,9 @@
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
export class WebProcessReloadService implements ProcessReloadServiceAbstraction {
constructor(private window: Window) {}
async startProcessReload(authService: AuthService): Promise<void> {
async startProcessReload(): Promise<void> {
this.window.location.reload();
}

View File

@@ -50,6 +50,7 @@ import {
import { canAccessEmergencyAccess } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent } from "@bitwarden/key-management-ui";
import { premiumInterestRedirectGuard } from "@bitwarden/web-vault/app/vault/guards/premium-interest-redirect/premium-interest-redirect.guard";
import { flagEnabled, Flags } from "../utils/flags";
@@ -630,7 +631,7 @@ const routes: Routes = [
children: [
{
path: "vault",
canActivate: [setupExtensionRedirectGuard],
canActivate: [premiumInterestRedirectGuard, setupExtensionRedirectGuard],
loadChildren: () => VaultModule,
},
{

View File

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* Web implementation of SystemService.
* The implementation is NOOP since these functions are not supported on web.
*/
export class WebSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -0,0 +1,88 @@
import { TestBed } from "@angular/core/testing";
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from "@angular/router";
import { BehaviorSubject } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { premiumInterestRedirectGuard } from "./premium-interest-redirect.guard";
describe("premiumInterestRedirectGuard", () => {
const _state = Object.freeze({}) as RouterStateSnapshot;
const emptyRoute = Object.freeze({ queryParams: {} }) as ActivatedRouteSnapshot;
const account = {
id: "account-id",
} as Account;
const activeAccount$ = new BehaviorSubject<Account | null>(account);
const createUrlTree = jest.fn();
const getPremiumInterest = jest.fn().mockResolvedValue(false);
const logError = jest.fn();
beforeEach(() => {
getPremiumInterest.mockClear();
createUrlTree.mockClear();
logError.mockClear();
activeAccount$.next(account);
TestBed.configureTestingModule({
providers: [
{ provide: Router, useValue: { createUrlTree } },
{ provide: AccountService, useValue: { activeAccount$ } },
{
provide: PremiumInterestStateService,
useValue: { getPremiumInterest },
},
{ provide: LogService, useValue: { error: logError } },
],
});
});
function runPremiumInterestGuard(route?: ActivatedRouteSnapshot) {
// Run the guard within injection context so `inject` works as you'd expect
// Pass state object to make TypeScript happy
return TestBed.runInInjectionContext(async () =>
premiumInterestRedirectGuard(route ?? emptyRoute, _state),
);
}
it("returns `true` when the user does not intend to setup premium", async () => {
getPremiumInterest.mockResolvedValueOnce(false);
expect(await runPremiumInterestGuard()).toBe(true);
});
it("redirects to premium subscription page when user intends to setup premium", async () => {
const urlTree = { toString: () => "/settings/subscription/premium" };
createUrlTree.mockReturnValueOnce(urlTree);
getPremiumInterest.mockResolvedValueOnce(true);
const result = await runPremiumInterestGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/settings/subscription/premium"], {
queryParams: { callToAction: "upgradeToPremium" },
});
expect(result).toBe(urlTree);
});
it("redirects to login when active account is missing", async () => {
const urlTree = { toString: () => "/login" };
createUrlTree.mockReturnValueOnce(urlTree);
activeAccount$.next(null);
const result = await runPremiumInterestGuard();
expect(createUrlTree).toHaveBeenCalledWith(["/login"]);
expect(result).toBe(urlTree);
});
it("returns `true` and logs error when getPremiumInterest throws an error", async () => {
const error = new Error("Premium interest check failed");
getPremiumInterest.mockRejectedValueOnce(error);
expect(await runPremiumInterestGuard()).toBe(true);
expect(logError).toHaveBeenCalledWith("Error in premiumInterestRedirectGuard", error);
});
});

View File

@@ -0,0 +1,37 @@
import { inject } from "@angular/core";
import { CanActivateFn, Router } from "@angular/router";
import { firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
export const premiumInterestRedirectGuard: CanActivateFn = async () => {
const router = inject(Router);
const accountService = inject(AccountService);
const premiumInterestStateService = inject(PremiumInterestStateService);
const logService = inject(LogService);
try {
const currentAcct = await firstValueFrom(accountService.activeAccount$);
if (!currentAcct) {
return router.createUrlTree(["/login"]);
}
const intendsToSetupPremium = await premiumInterestStateService.getPremiumInterest(
currentAcct.id,
);
if (intendsToSetupPremium) {
return router.createUrlTree(["/settings/subscription/premium"], {
queryParams: { callToAction: "upgradeToPremium" },
});
}
return true;
} catch (error) {
logService.error("Error in premiumInterestRedirectGuard", error);
return true;
}
};

View File

@@ -123,7 +123,7 @@ function displayHandoffMessage(client: string) {
? localeService.t("thisWindowWillCloseIn5Seconds")
: localeService.t("youMayCloseThisWindow");
h1.className = "tw-font-semibold";
h1.className = "tw-font-medium";
p.className = "tw-mb-4";
content.appendChild(h1);

View File

@@ -115,7 +115,7 @@
<button
type="button"
id="webauthn-button"
class="!tw-text-contrast disabled:!tw-text-muted disabled:hover:!tw-text-muted disabled:hover:tw-bg-secondary-300 disabled:hover:tw-border-secondary-300 disabled:hover:tw-no-underline disabled:tw-bg-secondary-300 disabled:tw-border-secondary-300 disabled:tw-cursor-not-allowed focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-block tw-border-2 tw-border-primary-600 tw-border-solid tw-font-semibold tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
class="!tw-text-contrast disabled:!tw-text-muted disabled:hover:!tw-text-muted disabled:hover:tw-bg-secondary-300 disabled:hover:tw-border-secondary-300 disabled:hover:tw-no-underline disabled:tw-bg-secondary-300 disabled:tw-border-secondary-300 disabled:tw-cursor-not-allowed focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-block tw-border-2 tw-border-primary-600 tw-border-solid tw-font-medium tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
></button>
</div>
</div>

View File

@@ -24,7 +24,7 @@
<button
type="button"
id="webauthn-button"
class="tw-cursor-pointer tw-bg-primary-600 tw-border-transparent tw-px-4 tw-py-2 tw-rounded-md hover:tw-bg-primary-700 tw-transition-colors tw-font-semibold tw-text-contrast tw-text-lg"
class="tw-cursor-pointer tw-bg-primary-600 tw-border-transparent tw-px-4 tw-py-2 tw-rounded-md hover:tw-bg-primary-700 tw-transition-colors tw-font-medium tw-text-contrast tw-text-lg"
></button>
</div>
</div>

View File

@@ -9,7 +9,7 @@
<button
type="button"
id="webauthn-button"
class="tw-block !tw-text-contrast focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-border-2 tw-border-primary-600 tw-border-solid tw-font-semibold tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
class="tw-block !tw-text-contrast focus-visible:tw-ring-2 focus-visible:tw-ring-offset-2 focus-visible:tw-ring-primary-600 focus-visible:tw-z-10 focus:tw-outline-none hover:tw-bg-primary-700 hover:tw-border-primary-700 hover:tw-no-underline tw-bg-primary-600 tw-border-2 tw-border-primary-600 tw-border-solid tw-font-medium tw-no-underline tw-px-3 tw-py-1.5 tw-rounded-full tw-text-center tw-transition tw-w-full"
></button>
</body>
</html>

View File

@@ -23,6 +23,9 @@
"passwordRisk": {
"message": "Password Risk"
},
"noEditPermissions": {
"message": "You don't have permission to edit this item"
},
"reviewAtRiskPasswords": {
"message": "Review at-risk passwords (weak, exposed, or reused) across applications. Select your most critical applications to prioritize security actions for your users to address at-risk passwords."
},
@@ -376,6 +379,12 @@
"selectCriticalApplicationsDescription": {
"message": "Select which applications are most critical to your organization, then assign security tasks to members to resolve risks."
},
"reviewNewApplications": {
"message": "Review new applications"
},
"reviewNewApplicationsDescription": {
"message": "We've highlighted at-risk items for new applications stored in Admin console that have weak, exposed, or reused passwords."
},
"clickIconToMarkAppAsCritical": {
"message": "Click the star icon to mark an app as critical"
},

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "newClientOrganization" | i18n }}
</span>
<div bitDialogContent>
@@ -22,16 +22,16 @@
<div class="tw-relative">
<div
*ngIf="planCard.selected"
class="tw-bg-primary-600 tw-text-center !tw-text-contrast tw-text-sm tw-font-bold tw-py-1 group-hover/plan-card-container:tw-bg-primary-700"
class="tw-bg-primary-600 tw-text-center !tw-text-contrast tw-text-sm tw-font-medium tw-py-1 group-hover/plan-card-container:tw-bg-primary-700"
>
{{ "selected" | i18n }}
</div>
<div class="tw-pl-5 tw-py-4 tw-pr-4" [ngClass]="{ 'tw-pt-10': !planCard.selected }">
<h3 class="tw-text-2xl tw-font-bold tw-uppercase">{{ planCard.name }}</h3>
<span class="tw-text-2xl tw-font-semibold">{{
<h3 class="tw-text-2xl tw-font-medium tw-uppercase">{{ planCard.name }}</h3>
<span class="tw-text-2xl tw-font-medium">{{
planCard.getMonthlyCost() | currency: "$"
}}</span>
<span class="tw-text-sm tw-font-bold"
<span class="tw-text-sm tw-font-medium"
>/ {{ planCard.getTimePerMemberLabel() | i18n }}</span
>
</div>

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "updateName" | i18n }}
<small class="tw-text-muted">{{ dialogParams.organization.name }}</small>
</span>

View File

@@ -18,7 +18,7 @@
<div *ngIf="!loading && !authed">
<p bitTypography="body1" class="tw-text-center">
{{ providerName }}
<span bitTypography="body1" class="tw-font-bold">{{ email }}</span>
<span bitTypography="body1" class="tw-font-medium">{{ email }}</span>
</p>
<p bitTypography="body1">{{ "joinProviderDesc" | i18n }}</p>
<hr />

View File

@@ -67,7 +67,7 @@
(change)="dataSource.checkAllFilteredUsers($any($event.target).checked)"
id="selectAll"
/>
<label class="tw-mb-0 !tw-font-bold !tw-text-muted" for="selectAll">
<label class="tw-mb-0 !tw-font-medium !tw-text-muted" for="selectAll">
{{ "all" | i18n }}
</label>
</th>

View File

@@ -9,9 +9,7 @@ const routes: Routes = [
{ path: "", pathMatch: "full", redirectTo: "risk-insights" },
{
path: "risk-insights",
canActivate: [
organizationPermissionsGuard((org) => org.useRiskInsights && org.canAccessReports),
],
canActivate: [organizationPermissionsGuard((org) => org.canAccessReports)],
component: RiskInsightsComponent,
data: {
titleId: "RiskInsights",

Some files were not shown because too many files have changed in this diff Show More