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:
3
.github/renovate.json5
vendored
3
.github/renovate.json5
vendored
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
58
apps/browser/src/auth/services/extension-lock.service.ts
Normal file
58
apps/browser/src/auth/services/extension-lock.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ const actionButtonStyles = ({
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
|
||||
${disabled || isLoading
|
||||
? `
|
||||
|
||||
@@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css`
|
||||
${baseTextStyles}
|
||||
|
||||
color: ${themes[theme].primary[600]};
|
||||
font-weight: 700;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
10
apps/cli/src/key-management/cli-process-reload.service.ts
Normal file
10
apps/cli/src/key-management/cli-process-reload.service.ts
Normal 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> {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
10
apps/cli/src/platform/services/cli-system.service.ts
Normal file
10
apps/cli/src/platform/services/cli-system.service.ts
Normal 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> {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
3
apps/desktop/desktop_native/Cargo.lock
generated
3
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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"];
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod impersonate;
|
||||
mod log;
|
||||
mod main;
|
||||
|
||||
pub(crate) use main::main;
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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
|
||||
browser’s 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 @@ browser’s 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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +262,7 @@ const safeProviders: SafeProvider[] = [
|
||||
BiometricStateService,
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
AuthServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"noEditPermissions": {
|
||||
"message": "You don't have permission to edit this item"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Welcome back"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>() },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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">
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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">
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
10
apps/web/src/app/platform/web-system.service.ts
Normal file
10
apps/web/src/app/platform/web-system.service.ts
Normal 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> {}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user